From 1e6ed7179a3ffc5b4b7a0f4968a6dfa0b5209ec1 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:52:19 +0530 Subject: [PATCH 01/98] docs: record dev branch delivery workflow Co-Authored-By: Codex --- AGENTS.md | 8 ++++++++ README.md | 6 ++++++ docs/CHANGELOG.md | 8 +++++++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 31d42f8..025b6e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,3 +16,11 @@ A phase with failing or skipped tests is not done. 6. Secrets: none in the repo, ever. No analytics or third-party SDKs beyond §6 without asking. 7. Accessibility and dark mode are part of "done" in every phase, not a final polish pass. + +## Branch delivery workflow + +The owner merged the A0 foundation and requested this workflow on 2026-10-16: + +- Start ongoing work from `dev`, commit there, and push to `origin/dev`. +- Finish the phase's implementation and validation before opening its final `dev` → `main` pull request. +- Keep `main` for reviewed merges. Preserve the owner's configured commit identity and the required Codex co-author trailer. diff --git a/README.md b/README.md index da8e3c1..66a0311 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ iOS 17+ native SwiftUI app. Start with [the PRD](docs/PRD.md); current scope is **A0 Base**. Each later phase waits until its predecessor is merged. +## Development workflow + +Use `dev` for ongoing changes and push commits to `origin/dev`. After implementation and CI validation finish, open the final pull request from `dev` to `main`. The A0 foundation is already merged into `main`. + +## Build and test + ```sh git submodule update --init --recursive brew install xcodegen librsvg diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bdabc43..04b9728 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,7 +8,7 @@ Networking uses Apple's generated client and URLSession transport, cookie/creden Compiler integration: Apple's generator 1.10 treats a schema referenced as multipart content as a multipart enum, even when the same schema is also used for JSON. The build projection selects only the refresh endpoint's documented JSON request representation; a test proves every remaining operation, model and field equals the immutable contract. No API fields are invented or edited. URLSession transport 1.1 uses buffered mode so our redirect delegate governs every request. Standard framework detail-only and 400 field errors use defensive dynamic JSON parsing, as specified in the latest owner PRD. -Validation: six Python generator/provenance/contrast/Release-ATS tests and repository lint run locally and in CI. macOS CI compiles and runs 19 XCTest networking/state/theme tests using URLProtocol fakes plus three XCUITest flows covering connect, every member tab, server failure, dark mode and Dynamic Type. The native navigation and tab bar appearances bind adaptive semantic colors, with contrast assertions for normal/selected labels and icons in every tab layout and both color schemes. The [current PR checks](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/pull/1/checks) are the completion gate: A0 is complete only when all checks pass without skipped tests. The workflow exports UI PNGs and retains the xcresult artifact. No Mac is available in the authoring environment. +Validation: six Python generator/provenance/contrast/Release-ATS tests and repository lint run locally and in CI. macOS CI compiles and runs 19 XCTest networking/state/theme tests using URLProtocol fakes plus three XCUITest flows covering connect, every member tab, server failure, dark mode and Dynamic Type. The native navigation and tab bar appearances bind adaptive semantic colors, with contrast assertions for normal/selected labels and icons in every tab layout and both color schemes. The [development CI checks](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions?query=branch%3Adev) are the completion gate: A0 is complete only when all checks pass without skipped tests. The workflow exports UI PNGs and retains the xcresult artifact. No Mac is available in the authoring environment. Parity checklist: - [x] First launch: “Connect to your Space Works server”, “Server URL”, “Connect”, shared helper text; validates generated `/api/v1/health/` before saving. @@ -20,3 +20,9 @@ Parity checklist: - [x] Shared fallback and special error copy; 24 simultaneous unauthorized calls require exactly one refresh in the URLProtocol fake-server test. Scope decisions: no A1 work begins before this phase merges. Build-time Python/librsvg/XcodeGen tools do not add app SDKs. No additional analytics or app SDKs. Full schema contains 635 paths and the API prefix; the runtime always supplies a validated origin instead of schema localhost examples. Gilroy app embedding remains an owner licensing decision; use system fonts until cleared. Staging server/accounts are not required for A0's deterministic contract tests and are still owner-supplied for later integration. + +## Development branch workflow (2026-10-16) + +The owner made the repository public and merged A0 through PR #1, including the native appearance fixes. `dev` starts from the merged `main` commit `cb9154134e527ee1c5abf15d853a4c3ccac187c5`. Ongoing work is committed and pushed on `dev`; its final pull request targets `main` after validation. The workflow is recorded in `AGENTS.md` and the README. + +The final source suite contains six Python tests, repository lint, 19 XCTest cases and three UI flows. [Development CI](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions?query=branch%3Adev) validates this branch before the final pull request. This documentation update preserves the merged application source. From 55535f935483274fbbcefeab7a0010636aa151c8 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:28:55 +0530 Subject: [PATCH 02/98] feat(a1): add native device sign-in and secure session lifecycle Co-Authored-By: Codex --- AGENTS.md | 11 + .../Sources/SpaceWorksAPI/Networking.swift | 67 +++++- .../Sources/SpaceWorksAPI/TokenVault.swift | 53 +++++ .../openapi-generator-config.yaml | 8 + SpaceWorks/AppState.swift | 3 + SpaceWorks/AuthState.swift | 185 +++++++++++++++++ SpaceWorks/AuthView.swift | 190 ++++++++++++++++++ SpaceWorks/DebugAuthFixtures.swift | 18 ++ SpaceWorks/NativeAttestor.swift | 28 +++ SpaceWorks/RootView.swift | 86 +++++--- SpaceWorks/SpaceWorksApp.swift | 31 ++- SpaceWorksTests/AuthTests.swift | 120 +++++++++++ SpaceWorksUITests/MainFlowTests.swift | 96 +++++---- docs/CHANGELOG.md | 25 +++ docs/IMPLEMENTATION-PLAN.md | 31 +++ docs/LOCAL-DOCKER-VALIDATION.md | 46 +++++ project.yml | 3 + scripts/generate.py | 13 +- scripts/tests/test_generation.py | 11 +- 19 files changed, 942 insertions(+), 83 deletions(-) create mode 100644 Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/TokenVault.swift create mode 100644 SpaceWorks/AuthState.swift create mode 100644 SpaceWorks/AuthView.swift create mode 100644 SpaceWorks/DebugAuthFixtures.swift create mode 100644 SpaceWorks/NativeAttestor.swift create mode 100644 SpaceWorksTests/AuthTests.swift create mode 100644 docs/IMPLEMENTATION-PLAN.md create mode 100644 docs/LOCAL-DOCKER-VALIDATION.md diff --git a/AGENTS.md b/AGENTS.md index 025b6e5..2168c50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,3 +24,14 @@ The owner merged the A0 foundation and requested this workflow on 2026-10-16: - Start ongoing work from `dev`, commit there, and push to `origin/dev`. - Finish the phase's implementation and validation before opening its final `dev` → `main` pull request. - Keep `main` for reviewed merges. Preserve the owner's configured commit identity and the required Codex co-author trailer. + +## Complete-build execution addendum (owner, 2026-10-16) + +The owner requested the remaining phases as one batch, with Android and iOS agents in parallel. +Implement each phase sequentially on `dev`, with phase commits, tests, CI evidence, and a changelog +parity checklist. A green phase may proceed without an intermediate merge; the existing final +`dev` → `main` PR tracks the batch. The owner performs merges. Preserve `docs/PRD.md` byte-for-byte. +Phone and tablet layouts, portrait/landscape, light/dark and largest Dynamic Type are now in scope. +Use generated contract operations only. Record missing backend contracts as `backend-request` +issues and continue independent work when prerequisites permit. Local Docker is the development API; +development attestation and local HTTP must remain Debug-only. Store credentials only in Keychain. diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift index 701e94f..1a0d90e 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift @@ -65,10 +65,15 @@ public struct APIError: Error, LocalizedError, Sendable, Equatable { if let error = error as? ClientError { return retryDeadline(error.underlyingError) } return nil } + public static func status(_ error: Error) -> Int? { + if let error = error as? APIError { return error.status } + if let error = error as? ClientError { return status(error.underlyingError) } + return nil + } } /// Local credentials, not a hand-written API payload. Persist together in Keychain in A1. -public struct TokenPair: Sendable, Equatable { +public struct TokenPair: Sendable, Equatable, Codable { public let access: String public let refresh: String public init(access: String, refresh: String) { self.access = access; self.refresh = refresh } @@ -79,22 +84,40 @@ public actor RefreshCoordinator { private var pair: TokenPair? private var flight: Task? private let rotate: @Sendable (String) async throws -> TokenPair - public init(pair: TokenPair? = nil, rotate: @escaping @Sendable (String) async throws -> TokenPair) { - self.pair = pair; self.rotate = rotate + private let persist: @Sendable (TokenPair?) throws -> Void + private var generation = 0 + public init(pair: TokenPair? = nil, persist: @escaping @Sendable (TokenPair?) throws -> Void = { _ in }, + rotate: @escaping @Sendable (String) async throws -> TokenPair) { + self.pair = pair; self.rotate = rotate; self.persist = persist } public func current() -> TokenPair? { pair } + public func install(_ value: TokenPair?) throws { + generation += 1 + flight?.cancel() + flight = nil + pair = nil + try persist(value) + pair = value + } public func recover(rejectedAccess: String) async throws -> TokenPair { guard let current = pair else { throw APIError(status: 401, code: "signed_out", detail: "Sign in to continue.") } if current.access != rejectedAccess { return current } if let flight { return try await flight.value } let rotate = self.rotate + let generation = self.generation let task = Task { do { let updated = try await rotate(current.refresh) + guard self.generation == generation else { throw CancellationError() } + try self.persist(updated) + guard self.generation == generation else { throw CancellationError() } self.commit(updated) return updated } catch { - self.commit(nil) + if self.generation == generation { + self.commit(nil) + try? self.persist(nil) + } throw error } } @@ -221,15 +244,18 @@ public enum DeviceSession { public final class SpaceWorksService: Sendable { private let client: Client + private let publicClient: Client public let refresh: RefreshCoordinator public let makerspace: MakerspaceContext public init(origin: ServerOrigin, session: URLSession = DeviceSession.make(), initialTokens: TokenPair? = nil, - makerspace: MakerspaceContext = MakerspaceContext()) { + makerspace: MakerspaceContext = MakerspaceContext(), + persist: @escaping @Sendable (TokenPair?) throws -> Void = { _ in }) { // Buffered mode keeps URLSession's session delegate in control of redirects. Streaming // transport installs its own task delegate and would bypass that guarantee. let transport = URLSessionTransport(configuration: .init(session: session, httpBodyProcessingMode: .buffered)) let raw = Client(serverURL: origin.url, transport: transport, middlewares: [ErrorMiddleware()]) - let coordinator = RefreshCoordinator(pair: initialTokens) { token in + self.publicClient = raw + let coordinator = RefreshCoordinator(pair: initialTokens, persist: persist) { token in let response = try await raw.api_v1_auth_device_refresh_create(.init(body: .json(.init(refresh: token)))) let value = try response.ok.body.json return TokenPair(access: value.access, refresh: value.refresh) @@ -242,4 +268,33 @@ public final class SpaceWorksService: Sendable { let result = try await client.api_v1_health_retrieve() _ = try result.ok.body.json } + + public func configuration() async throws -> Components.Schemas.PublicConfig { + try await publicClient.api_v1_config_retrieve().ok.body.json + } + public func challenge(appID: String, development: Bool) async throws -> Components.Schemas.DeviceChallengeResponse { + let input = Components.Schemas.DeviceIdentity(platform: .apple, app_id: appID, environment: development ? .development : .production) + return try await publicClient.api_v1_auth_device_attestation_challenge_create(.init(body: .json(input))).ok.body.json + } + public func signIn(appID: String, development: Bool, username: String, password: String, challenge: String, + attestationJSON: Data) async throws -> Components.Schemas.DeviceLoginResult { + let input = try Components.Schemas.DeviceLogin(platform: .apple, app_id: appID, environment: development ? .development : .production, + username: username, password: password, challenge: challenge, + attestation: JSONDecoder().decode(OpenAPIValueContainer.self, from: attestationJSON)) + return try await publicClient.api_v1_auth_device_login_create(.init(body: .json(input))).ok.body.json + } + public func me() async throws -> Components.Schemas.AuthUserPayload { + try await client.api_v1_auth_me_retrieve().ok.body.json + } + public func verify(challenge: String, code: String, enrolling: Bool = false) async throws -> Components.Schemas.MFACompleted { + let input = Components.Schemas.MFAVerify(challenge: challenge, code: code) + if enrolling { return try await publicClient.api_v1_auth_mfa_confirm_create(.init(body: .json(input))).ok.body.json } + return try await publicClient.api_v1_auth_mfa_verify_create(.init(body: .json(input))).ok.body.json + } + public func enroll(challenge: String) async throws -> Components.Schemas.MFAEnrollment { + try await publicClient.api_v1_auth_mfa_enroll_create(.init(body: .json(.init(challenge: challenge)))).ok.body.json + } + public func signOut() async throws { + _ = try await client.api_v1_auth_device_logout_create().ok.body.json + } } diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/TokenVault.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/TokenVault.swift new file mode 100644 index 0000000..bd9feed --- /dev/null +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/TokenVault.swift @@ -0,0 +1,53 @@ +import Foundation +import Security +import CryptoKit + +/// One Keychain value contains the whole pair; updating it is atomic. Nothing is stored in defaults. +public protocol TokenStorage: Sendable { + func load() throws -> TokenPair? + func store(_ pair: TokenPair?) throws +} + +public struct TokenVault: TokenStorage { + private let account: String + private let service: String + public init(origin: ServerOrigin, service: String = "org.spaceworks.device-session") { + account = SHA256.hash(data: Data(origin.url.absoluteString.utf8)).map { String(format: "%02x", $0) }.joined() + self.service = service + } + private var query: [String: Any] { + [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, + kSecAttrAccount as String: account, kSecAttrSynchronizable as String: false] + } + public func load() throws -> TokenPair? { + var query = self.query + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + try check(status) + guard let data = result as? Data else { throw vaultError } + return try JSONDecoder().decode(TokenPair.self, from: data) + } + public func store(_ pair: TokenPair?) throws { + guard let pair else { + let status = SecItemDelete(query as CFDictionary) + if status != errSecItemNotFound { try check(status) } + return + } + let value = try JSONEncoder().encode(pair) + let changes: [String: Any] = [kSecValueData as String: value, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly] + let status = SecItemUpdate(query as CFDictionary, changes as CFDictionary) + if status == errSecItemNotFound { + try check(SecItemAdd(query.merging(changes) { _, new in new } as CFDictionary, nil)) + } else { try check(status) } + } + private var vaultError: APIError { + APIError(status: 0, code: "secure_storage_unavailable", detail: "Secure storage is unavailable. Unlock your device and try again.") + } + private func check(_ status: OSStatus) throws { + guard status == errSecSuccess else { throw vaultError } + } +} diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml index 983ef86..ebe0fde 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml @@ -6,3 +6,11 @@ filter: operations: - api_v1_health_retrieve - api_v1_auth_device_refresh_create + - api_v1_auth_device_attestation_challenge_create + - api_v1_auth_device_login_create + - api_v1_auth_device_logout_create + - api_v1_auth_me_retrieve + - api_v1_auth_mfa_verify_create + - api_v1_auth_mfa_enroll_create + - api_v1_auth_mfa_confirm_create + - api_v1_config_retrieve diff --git a/SpaceWorks/AppState.swift b/SpaceWorks/AppState.swift index c3edd2d..20bee31 100644 --- a/SpaceWorks/AppState.swift +++ b/SpaceWorks/AppState.swift @@ -13,6 +13,7 @@ final class AppState { private(set) var connecting = false private(set) var error: String? private(set) var retryAfter: Date? + private(set) var connectedOrigin: ServerOrigin? let locked: Bool private let settings: UserDefaults private let health: @Sendable (ServerOrigin) async throws -> Void @@ -52,6 +53,7 @@ final class AppState { guard !Task.isCancelled else { return } settings.set(origin.url.absoluteString, forKey: "server-origin") serverInput = origin.url.absoluteString + connectedOrigin = origin screen = .shell } catch { self.error = APIError.display(error) @@ -63,5 +65,6 @@ final class AppState { guard !locked else { return } settings.removeObject(forKey: "server-origin") screen = .connect + connectedOrigin = nil } } diff --git a/SpaceWorks/AuthState.swift b/SpaceWorks/AuthState.swift new file mode 100644 index 0000000..fda206f --- /dev/null +++ b/SpaceWorks/AuthState.swift @@ -0,0 +1,185 @@ +import Foundation +import Observation +import SpaceWorksAPI + +@MainActor @Observable +final class AuthState { + enum Stage: Equatable { case restoring, signIn, mfa, recoveryCodes, signedIn } + private(set) var stage: Stage = .restoring + var username = "" + var password = "" + var code = "" + private(set) var busy = false + private(set) var error: String? + private(set) var retryAfter: Date? + private(set) var user: Components.Schemas.AuthUserPayload? + private(set) var mfa: Components.Schemas.MFAChallengeResponse? + private(set) var enrollment: Components.Schemas.MFAEnrollment? + private(set) var recoveryCodes: [String] = [] + private(set) var selectedID: Int? + private(set) var configuration: Components.Schemas.PublicConfig? + let service: SpaceWorksService + private let attest: @Sendable (String) async throws -> Data + private let settings: UserDefaults + private let storage: any TokenStorage + private var restored = false + private var mfaExpires: Date? + + init(origin: ServerOrigin, session: URLSession = DeploymentSession.make(), settings: UserDefaults = .standard, + storage: (any TokenStorage)? = nil, + attest: @escaping @Sendable (String) async throws -> Data = { try await NativeAttestor.attest($0) }) { + let storage = storage ?? TokenVault(origin: origin) + self.storage = storage + self.attest = attest + self.settings = settings + var pair: TokenPair? + var storageError: String? + do { pair = try storage.load() } catch { storageError = APIError.display(error) } + service = SpaceWorksService(origin: origin, session: session, initialTokens: pair, persist: { try storage.store($0) }) + error = storageError + } + var memberships: [Components.Schemas.AuthMembership] { + // Organization-derived authority is excluded by the mobile scope. /me supplies active memberships. + user?.makerspaces.filter { $0.source == .membership } ?? [] + } + var selectedMembership: Components.Schemas.AuthMembership? { memberships.first { $0.id == selectedID } } + var displayName: String { user?.display_name.isEmpty == false ? user!.display_name : user?.username ?? "" } + var passwordEnabled: Bool { + guard let configuration, let data = try? JSONEncoder().encode(configuration), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let login = object["password_login"] as? [String: Any] else { return true } + // PublicConfig's extensible object; same documented switch consumed by MemberAuthPanel. + return login["enabled"] as? Bool != false + } + // Fail closed until the schema supplies native_staff authority (backend 1a). + var staffToolsAvailable: Bool { false } + + func restore() async { + guard !restored else { return } + restored = true + do { + configuration = try await service.configuration() + guard await service.refresh.current() != nil else { stage = .signIn; return } + try await loadIdentity() + } catch { + error = APIError.display(error) + if isUnauthorized(error) { try? await service.refresh.install(nil) } + stage = .signIn + } + } + func signIn() async { + guard !busy, (retryAfter ?? .distantPast) <= Date() else { return } + guard !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !password.isEmpty else { + error = "Enter your email or username and password."; return + } + busy = true; error = nil + defer { busy = false; password = "" } + do { + let challenge = try await service.challenge(appID: Brand.bundleID, development: NativeAttestor.development) + let expires = Date().addingTimeInterval(TimeInterval(challenge.expires_in)) + let proof = try await attest(challenge.challenge) + guard Date() < expires else { throw APIError(status: 0, code: "challenge_expired", detail: "Your sign-in expired. Try again.") } + let result = try await service.signIn(appID: Brand.bundleID, development: NativeAttestor.development, + username: username.trimmingCharacters(in: .whitespacesAndNewlines), password: password, + challenge: challenge.challenge, attestationJSON: proof) + switch result { + case .case1(let tokens): + try await accept(TokenPair(access: tokens.access, refresh: tokens.refresh)) + case .case2(let challenge): + mfa = challenge + mfaExpires = Date().addingTimeInterval(TimeInterval(challenge.expires_in)) + if challenge.enrollment_required { enrollment = try await service.enroll(challenge: challenge.challenge) } + stage = .mfa + } + } catch { fail(error) } + } + func verify() async { + guard !busy, let mfa, (retryAfter ?? .distantPast) <= Date() else { return } + guard (mfaExpires ?? .distantPast) > Date() else { + backToSignIn(); error = "Your sign-in expired. Try again."; return + } + guard code.count >= 6 else { error = "Enter your authenticator or recovery code."; return } + busy = true; error = nil + defer { busy = false; code = "" } + do { + let result = try await service.verify(challenge: mfa.challenge, code: code, enrolling: mfa.enrollment_required) + let grantData = try result.device_grant.map { try JSONEncoder().encode($0) } + let grant = grantData.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + guard let refresh = result.refresh, !refresh.isEmpty, !result.access.isEmpty, + result.surface != "verification_only", let grantID = grant?["id"] as? String, UUID(uuidString: grantID) != nil else { + throw APIError(status: 0, code: "native_session_missing", detail: "This server did not provide a device session. Contact your makerspace.") + } + recoveryCodes = result.recovery_codes ?? [] + try await accept(TokenPair(access: result.access, refresh: refresh)) + if !recoveryCodes.isEmpty { stage = .recoveryCodes } + } catch { fail(error) } + } + func confirmRecoveryCodes() { recoveryCodes = []; stage = .signedIn } + func backToSignIn() { + guard !busy else { return } + mfa = nil; mfaExpires = nil; enrollment = nil; code = ""; error = nil; stage = .signIn + } + func select(_ id: Int) async { + do { + try await service.makerspace.select(id, activeMemberships: Set(memberships.map(\.id))) + selectedID = id + settings.set(id, forKey: "makerspace-selection") + } catch { fail(error) } + } + func foreground() async { + guard stage == .signedIn, !busy else { return } + busy = true + defer { busy = false } + do { try await loadIdentity() } catch { + fail(error) + if isUnauthorized(error) { try? await service.refresh.install(nil); clear() } + } + } + func signOut() async { + guard !busy else { return } + busy = true; error = nil + defer { busy = false } + var revocationFailed = false + do { try await service.signOut() } catch { revocationFailed = true } + do { + try await service.refresh.install(nil) + clear() + if revocationFailed { error = "Signed out on this device. Server revocation could not be confirmed." } + } catch { clear(); fail(error) } + } + private func accept(_ pair: TokenPair) async throws { + guard !pair.access.isEmpty, !pair.refresh.isEmpty else { + throw APIError(status: 0, code: "native_session_missing", detail: "This server did not provide a device session. Contact your makerspace.") + } + try await service.refresh.install(pair) + do { try await loadIdentity() } catch { + try? await service.refresh.install(nil) + throw error + } + } + private func loadIdentity() async throws { + let identity = try await service.me() + guard !identity.is_superuser else { + try? await service.refresh.install(nil) + throw APIError(status: 403, code: "device_superadmin_not_supported", detail: "Use the web console") + } + user = identity + let ids = Set(memberships.map(\.id)) + let saved = selectedID ?? settings.object(forKey: "makerspace-selection") as? Int + let selection = saved.flatMap { ids.contains($0) ? $0 : nil } ?? (memberships.count == 1 ? memberships.first?.id : nil) + try await service.makerspace.select(selection, activeMemberships: ids) + selectedID = selection + stage = .signedIn + } + private func clear() { + user = nil; selectedID = nil; mfa = nil; enrollment = nil; recoveryCodes = [] + password = ""; code = ""; settings.removeObject(forKey: "makerspace-selection"); stage = .signIn + } + private func fail(_ failure: Error) { + guard !(failure is CancellationError) else { return } + error = APIError.display(failure); retryAfter = APIError.retryDeadline(failure) + } + private func isUnauthorized(_ failure: Error) -> Bool { + APIError.status(failure) == 401 + } +} diff --git a/SpaceWorks/AuthView.swift b/SpaceWorks/AuthView.swift new file mode 100644 index 0000000..21d6fa9 --- /dev/null +++ b/SpaceWorks/AuthView.swift @@ -0,0 +1,190 @@ +import SwiftUI +import SpaceWorksAPI +import CoreImage.CIFilterBuiltins + +struct AuthRoot: View { + let state: AppState + @State private var auth: AuthState + @Environment(\.scenePhase) private var scenePhase + init(state: AppState, origin: ServerOrigin) { + self.state = state + _auth = State(initialValue: AuthState(origin: origin)) + } + var body: some View { + Group { + switch auth.stage { + case .restoring: ProgressView("Opening your makerspace…").frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.swBg) + case .signIn: SignInView(auth: auth, state: state) + case .mfa, .recoveryCodes: MFAView(auth: auth) + case .signedIn: + if auth.selectedID == nil { MakerspacePicker(auth: auth) } + else { AppShell(state: state, auth: auth) } + } + } + .task { await auth.restore() } + .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await auth.foreground() } } } + } +} + +struct SWCard: View { + @ViewBuilder var content: Content + var body: some View { + VStack(alignment: .leading, spacing: 16) { content } + .padding(24).frame(maxWidth: .infinity, alignment: .leading) + .background(Color.swPanel, in: RoundedRectangle(cornerRadius: 16)) + .overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.swLine)) + } +} +struct SWError: View { + let message: String + var body: some View { + Label(message, systemImage: "exclamationmark.circle.fill") + .foregroundStyle(Color.swDanger).accessibilityIdentifier("auth-error") + .accessibilityAddTraits(.updatesFrequently) + } +} +struct SWPrimary: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label.font(.headline).frame(maxWidth: .infinity, minHeight: 48).padding(.horizontal, 16) + .foregroundStyle(Color.swOnAccent) + .background(configuration.isPressed ? Color.swAccentActive : Color.swAccent, in: RoundedRectangle(cornerRadius: 10)) + } +} +struct SignInView: View { + @Bindable var auth: AuthState + let state: AppState + @State private var showPassword = false + @Environment(\.dynamicTypeSize) private var typeSize + var body: some View { + GeometryReader { geometry in + ScrollView { + HStack(alignment: .center, spacing: 40) { + VStack(alignment: .leading, spacing: 24) { + Text(Brand.appName).font(.headline).foregroundStyle(Color.swAccentInk) + Text("Welcome back.").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text("Your makerspace, in your pocket.").foregroundStyle(Color.swMuted) + SWCard { + Text("Sign in").font(.title2.bold()) + if !auth.passwordEnabled { + Text("This deployment signs in through an identity provider. Ask your makerspace to enable native sign-in.") + .foregroundStyle(Color.swMuted) + } else { + field("Email or username") { + TextField("Email or username", text: $auth.username).textContentType(.username) + .textInputAutocapitalization(.never).autocorrectionDisabled().accessibilityIdentifier("username") + } + field("Password") { + HStack { + Group { + if showPassword { TextField("Password", text: $auth.password) } + else { SecureField("Password", text: $auth.password) } + }.textContentType(.password).accessibilityIdentifier("password") + Button { showPassword.toggle() } label: { Image(systemName: showPassword ? "eye.slash" : "eye") } + .accessibilityLabel(showPassword ? "Hide password" : "Show password").frame(minWidth: 44, minHeight: 44) + } + } + if let error = auth.error { SWError(message: error) } + TimelineView(.periodic(from: .now, by: 1)) { context in + Button(auth.busy ? "Signing in…" : "Sign in") { Task { await auth.signIn() } } + .buttonStyle(SWPrimary()).disabled(auth.busy || (auth.retryAfter ?? .distantPast) > context.date) + .accessibilityIdentifier("sign-in") + } + } + }.disabled(auth.busy) + if !state.locked { Button("Change server") { state.changeServer() }.frame(minHeight: 44).disabled(auth.busy) } + }.frame(maxWidth: 460) + if geometry.size.width >= 900 && !typeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 24) { + Image(systemName: "wrench.and.screwdriver").font(.system(size: 64)).foregroundStyle(Color.swInk) + Text("Less searching.\nMore making.").font(.largeTitle.bold()) + Text("Borrow hardware. Make room for your next idea.").font(.title3) + }.padding(40).frame(maxWidth: 460, minHeight: 460, alignment: .leading) + .background(Color.swSuccess, in: RoundedRectangle(cornerRadius: 28)).foregroundStyle(Color.swOnSuccess) + .accessibilityHidden(true) + } + }.padding(24).frame(maxWidth: .infinity, minHeight: geometry.size.height) + }.background(Color.swBg).scrollDismissesKeyboard(.interactively) + } + } + private func field(_ title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title).font(.headline) + content().padding(.horizontal, 12).frame(minHeight: 52) + .background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)) + } + } +} + +struct MFAView: View { + @Bindable var auth: AuthState + var body: some View { + ScrollView { + SWCard { + Image(systemName: "lock.shield").font(.largeTitle).foregroundStyle(Color.swAccentInk).accessibilityHidden(true) + if auth.stage == .recoveryCodes { + Text("Save your recovery codes").font(.largeTitle.bold()) + Text("Keep these somewhere safe. Each code works once.").foregroundStyle(Color.swMuted) + ForEach(auth.recoveryCodes, id: \.self) { Text($0).font(.body.monospaced()).textSelection(.enabled) } + Button("I saved my codes. Continue") { auth.confirmRecoveryCodes() }.buttonStyle(SWPrimary()) + } else { + Text(auth.mfa?.enrollment_required == true ? "Set up two-step sign-in" : "Verify your sign-in").font(.largeTitle.bold()) + if let enrollment = auth.enrollment { + Text("Add this account to your authenticator app.").foregroundStyle(Color.swMuted) + QRCodeView(value: enrollment.provisioning_uri) + Text("Can't scan? Enter this key").font(.headline) + Text(enrollment.secret).font(.body.monospaced()).textSelection(.enabled) + } + Text("Authenticator or recovery code").font(.headline) + TextField("Authenticator or recovery code", text: $auth.code).textContentType(.oneTimeCode) + .textInputAutocapitalization(.never).autocorrectionDisabled().padding(16) + .background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)).accessibilityIdentifier("mfa-code") + if let error = auth.error { SWError(message: error) } + Button(auth.busy ? "Verifying…" : "Verify") { Task { await auth.verify() } } + .buttonStyle(SWPrimary()).disabled(auth.busy).accessibilityIdentifier("verify") + Button("Back to sign in") { auth.backToSignIn() }.frame(minHeight: 44).disabled(auth.busy) + } + }.padding(24).frame(maxWidth: 560).frame(maxWidth: .infinity) + }.background(Color.swBg) + } +} +struct QRCodeView: View { + let value: String + var body: some View { + if let image = image { + Image(uiImage: image).interpolation(.none).resizable().scaledToFit().frame(maxWidth: 240, maxHeight: 240) + .accessibilityLabel("Authenticator setup QR code") + } + } + private var image: UIImage? { + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(value.utf8) + guard let output = filter.outputImage, + let cg = CIContext().createCGImage(output, from: output.extent) else { return nil } + return UIImage(cgImage: cg) + } +} +struct MakerspacePicker: View { + let auth: AuthState + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + Text("Choose a makerspace").font(.largeTitle.bold()) + if auth.memberships.isEmpty { + Text("You haven’t joined a makerspace yet.").foregroundStyle(Color.swMuted) + } + ForEach(auth.memberships, id: \.id) { membership in + Button { Task { await auth.select(membership.id) } } label: { + SWCard { + Text(membership.slug).font(.title2.bold()) + Text(membership.role_name).foregroundStyle(Color.swMuted) + } + }.buttonStyle(.plain).accessibilityIdentifier("makerspace-\(membership.id)") + } + if let error = auth.error { SWError(message: error) } + Button("Sign out") { Task { await auth.signOut() } }.frame(minHeight: 44) + }.padding(24).frame(maxWidth: 640).frame(maxWidth: .infinity) + }.background(Color.swBg) + } +} diff --git a/SpaceWorks/DebugAuthFixtures.swift b/SpaceWorks/DebugAuthFixtures.swift new file mode 100644 index 0000000..497a342 --- /dev/null +++ b/SpaceWorks/DebugAuthFixtures.swift @@ -0,0 +1,18 @@ +#if DEBUG +import Foundation + +/// Contract-shaped fixtures for UI tests only, selected by explicit process launch flags. +enum DebugAuthFixtures { + static let grant = #"{"id":"00000000-0000-0000-0000-000000000001","platform":"apple","app_id":"org.spaceworks.app","environment":"development","status":"active","attested_at":"2026-10-16T12:00:00Z","last_used_at":"2026-10-16T12:00:00Z","created_at":"2026-10-16T12:00:00Z"}"# + static let membership = #"{"id":7,"slug":"demo-workshop","role":"member","role_id":1,"role_name":"Member","role_slug":"member","source":"membership","actions":[],"can_configure_machine_types":false,"is_machine_only":false,"can_refer":false,"can_verify":false,"verified_at":null,"referrals_enabled":false}"# + static let secondMembership = membership.replacingOccurrences(of: #""id":7"#, with: #""id":8"#).replacingOccurrences(of: "demo-workshop", with: "community-lab") + static var user: String { user(memberships: [membership, secondMembership]) } + static func user(memberships: [String], superuser: Bool = false) -> String { + "{\"id\":12,\"username\":\"member\",\"email\":\"member@example.invalid\",\"display_name\":\"Maker\",\"phone\":\"\",\"email_verified\":true,\"role\":\"member\",\"is_superuser\":\(superuser),\"must_change_password\":false,\"makerspaces\":[\(memberships.joined(separator: ","))]}" + } + static var tokens: String { "{\"access\":\"test-access\",\"refresh\":\"test-refresh\",\"user\":\(user),\"device_grant\":\(grant)}" } + static let mfa = #"{"mfa_required":true,"enrollment_required":false,"challenge":"test-mfa-challenge-of-valid-length","expires_in":300}"# + static let config = #"{"email_enabled":false,"public_image_max_bytes":10485760,"password_login":{"member":true,"staff":true},"social_auth":{}}"# + static let enrollment = #"{"secret":"TESTAUTHENTICATORKEY","provisioning_uri":"otpauth://totp/SpaceWorks:maker?secret=TESTAUTHENTICATORKEY&issuer=SpaceWorks","qr_data_url":""}"# +} +#endif diff --git a/SpaceWorks/NativeAttestor.swift b/SpaceWorks/NativeAttestor.swift new file mode 100644 index 0000000..7eff274 --- /dev/null +++ b/SpaceWorks/NativeAttestor.swift @@ -0,0 +1,28 @@ +import Foundation +import DeviceCheck +import CryptoKit +import SpaceWorksAPI + +enum NativeAttestor { + static var development: Bool { + #if DEBUG + true + #else + false + #endif + } + static func attest(_ challenge: String) async throws -> Data { + #if DEBUG && targetEnvironment(simulator) + return Data(#"{"type":"development"}"#.utf8) + #else + let service = DCAppAttestService.shared + guard service.isSupported else { + throw APIError(status: 0, code: "attestation_unavailable", detail: "This device cannot verify this app. Try a supported iPhone.") + } + let key = try await service.generateKey() + let proof = try await service.attestKey(key, clientDataHash: Data(SHA256.hash(data: Data(challenge.utf8)))) + // PRD §7 specifies these attestation fields; the API deliberately accepts arbitrary JSON. + return try JSONSerialization.data(withJSONObject: ["key_id": key, "attestation": proof.base64EncodedString()]) + #endif + } +} diff --git a/SpaceWorks/RootView.swift b/SpaceWorks/RootView.swift index 3b028f0..4e8aed9 100644 --- a/SpaceWorks/RootView.swift +++ b/SpaceWorks/RootView.swift @@ -6,7 +6,8 @@ struct RootView: View { Group { switch state.screen { case .connect: ConnectView(state: state) - case .shell: AppShell(state: state) + case .shell: + if let origin = state.connectedOrigin { AuthRoot(state: state, origin: origin) } } } .tint(Color.swAccentInk) @@ -82,33 +83,70 @@ enum AppTab: String, CaseIterable, Identifiable { } } -private struct AppShell: View { +struct AppShell: View { let state: AppState + let auth: AuthState + @Environment(\.horizontalSizeClass) private var sizeClass + @State private var selected = AppTab.home var body: some View { - TabView { - ForEach(AppTab.allCases) { tab in - NavigationStack { - GeometryReader { geometry in - ScrollView { - VStack(spacing: 16) { - if tab == .home { - Text("Sign in").font(.title.bold()).accessibilityAddTraits(.isHeader) - Text("Sign-in will be available in the next phase.").foregroundStyle(Color.swMuted) - } - Image(systemName: tab.symbol).font(.largeTitle).accessibilityHidden(true) - Text("Nothing here yet.").font(.body) - if tab == .profile && !state.locked { - Button("Change server") { state.changeServer() }.frame(minHeight: 44) - } - }.padding(24).frame(maxWidth: .infinity, minHeight: geometry.size.height) - } - .background(Color.swBg) + if sizeClass == .regular { + NavigationSplitView { + List(AppTab.allCases) { tab in + Button { selected = tab } label: { + Text(tab.rawValue).frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) + }.buttonStyle(.plain).accessibilityIdentifier("nav-\(tab.rawValue)") + .foregroundStyle(selected == tab ? Color.swOnAccent : Color.swInk) + .listRowBackground(selected == tab ? Color.swSidebarActive : Color.swSidebar) + } + .scrollContentBackground(.hidden).background(Color.swSidebar) + .navigationTitle(Brand.appName) + } detail: { NavigationStack { MemberScreen(state: state, auth: auth, tab: selected) } } + } else { + TabView(selection: $selected) { + ForEach(AppTab.allCases) { tab in + NavigationStack { MemberScreen(state: state, auth: auth, tab: tab) } + .tabItem { Label(tab.rawValue, systemImage: tab.symbol) }.tag(tab) + } + } + } + } +} + +private struct MemberScreen: View { + let state: AppState + let auth: AuthState + let tab: AppTab + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + if tab == .home { + Text("Welcome back, \(auth.displayName).").font(.largeTitle.bold()) + Text("Your makerspace, in your pocket.").foregroundStyle(Color.swMuted) + } + if tab == .profile { + SWCard { + Text(auth.displayName).font(.title2.bold()) + Text(auth.user?.email ?? "").foregroundStyle(Color.swMuted).textSelection(.enabled) + if let error = auth.error { SWError(message: error) } + Button("Sign out") { Task { await auth.signOut() } }.frame(minHeight: 44).disabled(auth.busy) + } + } else { + SWCard { + Image(systemName: tab.symbol).font(.title).accessibilityHidden(true) + Text("Nothing here yet.").font(.headline) } - .navigationTitle(tab.rawValue) - }.tabItem { Label(tab.rawValue, systemImage: tab.symbol) } + } + }.padding(24).frame(maxWidth: 960, alignment: .leading).frame(maxWidth: .infinity) + }.background(Color.swBg).navigationTitle(tab.rawValue) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + ForEach(auth.memberships, id: \.id) { membership in + Button(membership.slug) { Task { await auth.select(membership.id) } } + } + } label: { Label(auth.selectedMembership?.slug ?? "Choose a makerspace", systemImage: "building.2") } + .accessibilityIdentifier("makerspace-switcher") } } - .tint(Color.swAccentInk) - // There is no Staff tab until A1 provides server-backed membership actions. } } diff --git a/SpaceWorks/SpaceWorksApp.swift b/SpaceWorks/SpaceWorksApp.swift index 5c6ae9e..b0ea6a7 100644 --- a/SpaceWorks/SpaceWorksApp.swift +++ b/SpaceWorks/SpaceWorksApp.swift @@ -9,6 +9,7 @@ struct SpaceWorksApp: App { if ProcessInfo.processInfo.arguments.contains("--ui-testing") { UserDefaults.standard.removeObject(forKey: "server-origin") UserDefaults.standard.removeObject(forKey: "dark-mode") + if let origin = try? ServerOrigin("https://ui-test.spaceworks.invalid") { try? TokenVault(origin: origin).store(nil) } } #endif } @@ -19,15 +20,21 @@ struct SpaceWorksApp: App { enum DeploymentHealth { static func check(_ origin: ServerOrigin) async throws { + let session = DeploymentSession.make() + defer { session.finishTasksAndInvalidate() } + try await SpaceWorksService(origin: origin, session: session).health() + } +} + +enum DeploymentSession { + static func make() -> URLSession { let config = DeviceSession.configuration() #if DEBUG if ProcessInfo.processInfo.arguments.contains("--ui-testing") { config.protocolClasses = [PreviewHealthProtocol.self] } #endif - let session = DeviceSession.make(configuration: config) - defer { session.finishTasksAndInvalidate() } - try await SpaceWorksService(origin: origin, session: session).health() + return DeviceSession.make(configuration: config) } } @@ -39,9 +46,23 @@ final class PreviewHealthProtocol: URLProtocol { } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func startLoading() { - let failure = ProcessInfo.processInfo.arguments.contains("--health-unavailable") + let arguments = ProcessInfo.processInfo.arguments + let path = "/" + request.url!.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let failure = arguments.contains("--health-unavailable") && path == "/api/v1/health" let code = failure ? 503 : 200 - let body = failure ? #"{"detail":"Try again later.","code":"service_unavailable"}"# : #"{"status":"ok"}"# + let body: String + if failure { body = #"{"detail":"Try again later.","code":"service_unavailable"}"# } + else { + switch path { + case "/api/v1/config": body = DebugAuthFixtures.config + case "/api/v1/auth/device/attestation-challenge": body = #"{"challenge":"fresh-test-challenge","expires_in":120}"# + case "/api/v1/auth/device/login": body = arguments.contains("--mfa") ? DebugAuthFixtures.mfa : DebugAuthFixtures.tokens + case "/api/v1/auth/me": body = DebugAuthFixtures.user + case "/api/v1/auth/mfa/verify": body = "{\"access\":\"test-access\",\"refresh\":\"test-refresh\",\"device_grant\":\(DebugAuthFixtures.grant),\"surface\":\"member\",\"user\":\(DebugAuthFixtures.user)}" + case "/api/v1/auth/device/logout": body = #"{"detail":"Signed out."}"# + default: body = #"{"status":"ok"}"# + } + } let response = HTTPURLResponse(url: request.url!, statusCode: code, httpVersion: nil, headerFields: ["Content-Type": "application/json"])! client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: Data(body.utf8)) diff --git a/SpaceWorksTests/AuthTests.swift b/SpaceWorksTests/AuthTests.swift new file mode 100644 index 0000000..7dd671d --- /dev/null +++ b/SpaceWorksTests/AuthTests.swift @@ -0,0 +1,120 @@ +import XCTest +import SpaceWorksAPI +@testable import SpaceWorks + +final class MemoryTokens: TokenStorage, @unchecked Sendable { + private let lock = NSLock() + private var pair: TokenPair? + var failWrites = false + init(_ pair: TokenPair? = nil) { self.pair = pair } + func load() throws -> TokenPair? { lock.lock(); defer { lock.unlock() }; return pair } + func store(_ pair: TokenPair?) throws { + lock.lock(); defer { lock.unlock() } + if failWrites { throw APIError(status: 0, code: "storage", detail: "Secure storage unavailable.") } + self.pair = pair + } +} + +@MainActor final class AuthTests: XCTestCase { + private let json = ["Content-Type": "application/json"] + private func state(storage: MemoryTokens = MemoryTokens(), handler: @escaping (URLRequest) throws -> (Int, String)) -> AuthState { + StubProtocol.lock.lock() + StubProtocol.handler = { request in + let (status, text) = try handler(request) + return (status, self.json, Data(text.utf8)) + } + StubProtocol.lock.unlock() + let config = DeviceSession.configuration(); config.protocolClasses = [StubProtocol.self] + return AuthState(origin: try! ServerOrigin("https://spaceworks.example"), session: DeviceSession.make(configuration: config), + settings: UserDefaults(suiteName: UUID().uuidString)!, storage: storage, + attest: { _ in Data(#"{"type":"development"}"#.utf8) }) + } + private func response(_ request: URLRequest) throws -> (Int, String) { + for header in ["Cookie", "Origin", "Referer"] { XCTAssertNil(request.value(forHTTPHeaderField: header)) } + switch request.url!.path { + case "/api/v1/config": return (200, DebugAuthFixtures.config) + case "/api/v1/auth/device/attestation-challenge": + let body = try JSONSerialization.jsonObject(with: requestBody(request)) as! [String: String] + XCTAssertEqual(body["platform"], "apple"); XCTAssertEqual(body["environment"], "development") + return (200, #"{"challenge":"fresh-challenge","expires_in":120}"#) + case "/api/v1/auth/device/login": + let body = try JSONSerialization.jsonObject(with: requestBody(request)) as! [String: Any] + XCTAssertEqual(body["challenge"] as? String, "fresh-challenge") + XCTAssertEqual((body["attestation"] as? [String: String])?["type"], "development") + XCTAssertEqual(body["username"] as? String, "maker") + return (200, DebugAuthFixtures.tokens) + case "/api/v1/auth/me": + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer test-access") + return (200, DebugAuthFixtures.user) + case "/api/v1/auth/device/logout": return (200, #"{"detail":"Signed out."}"#) + default: XCTFail("Unexpected API call: \(request.url!)"); return (500, "{}") + } + } + func testPasswordSignInPersistsBeforeIdentitySelectsAndLogsOut() async throws { + let storage = MemoryTokens() + let auth = state(storage: storage, handler: response) + await auth.restore(); XCTAssertEqual(auth.stage, .signIn) + auth.username = " maker "; auth.password = "password-fixture" + await auth.signIn() + XCTAssertEqual(auth.stage, .signedIn) + XCTAssertEqual(try storage.load(), TokenPair(access: "test-access", refresh: "test-refresh")) + XCTAssertEqual(auth.memberships.count, 2); XCTAssertNil(auth.selectedID) + await auth.select(7); XCTAssertEqual(auth.selectedID, 7) + XCTAssertEqual(auth.password, ""); XCTAssertFalse(auth.staffToolsAvailable) + await auth.signOut(); XCTAssertNil(try storage.load()); XCTAssertNil(auth.user) + XCTAssertEqual(auth.stage, .signIn) + } + func testMFARejectsAccessOnlyBrowserResult() async { + let auth = state { request in + if request.url!.path.hasSuffix("/login") { return (200, DebugAuthFixtures.mfa) } + if request.url!.path.hasSuffix("/verify") { return (200, "{\"access\":\"web-access\",\"surface\":\"member\",\"user\":\(DebugAuthFixtures.user)}") } + return try self.response(request) + } + await auth.restore(); auth.username = "maker"; auth.password = "password-fixture"; await auth.signIn() + XCTAssertEqual(auth.stage, .mfa) + auth.code = "123456"; await auth.verify() + XCTAssertEqual(auth.stage, .mfa); XCTAssertNil(auth.user) + XCTAssertEqual(auth.error, "This server did not provide a device session. Contact your makerspace.") + } + func testEnrollmentAndRecoveryCodesRequireConfirmation() async { + let auth = state { request in + switch request.url!.path { + case "/api/v1/auth/device/login": return (200, DebugAuthFixtures.mfa.replacingOccurrences(of: "\"enrollment_required\":false", with: "\"enrollment_required\":true")) + case "/api/v1/auth/mfa/enroll": return (200, DebugAuthFixtures.enrollment) + case "/api/v1/auth/mfa/confirm": return (200, "{\"access\":\"test-access\",\"refresh\":\"test-refresh\",\"device_grant\":\(DebugAuthFixtures.grant),\"surface\":\"member\",\"user\":\(DebugAuthFixtures.user),\"recovery_codes\":[\"fixture-recovery\"]}") + default: return try self.response(request) + } + } + await auth.restore(); auth.username = "maker"; auth.password = "password-fixture"; await auth.signIn() + XCTAssertNotNil(auth.enrollment); auth.code = "123456"; await auth.verify() + XCTAssertEqual(auth.stage, .recoveryCodes); XCTAssertEqual(auth.recoveryCodes, ["fixture-recovery"]) + auth.confirmRecoveryCodes(); XCTAssertEqual(auth.stage, .signedIn); XCTAssertTrue(auth.recoveryCodes.isEmpty) + } + func testWrappedRevokedSessionOnForegroundClearsIdentity() async { + let auth = state(storage: MemoryTokens(TokenPair(access: "test-access", refresh: "test-refresh"))) { request in + if request.url!.path.hasSuffix("/refresh") { return (401, #"{"detail":"Session revoked.","code":"revoked"}"#) } + return try self.response(request) + } + await auth.restore(); XCTAssertEqual(auth.stage, .signedIn) + StubProtocol.lock.lock() + StubProtocol.handler = { _ in (401, self.json, Data(#"{"detail":"Session revoked.","code":"revoked"}"#.utf8)) } + StubProtocol.lock.unlock() + await auth.foreground(); XCTAssertEqual(auth.stage, .signIn); XCTAssertNil(auth.user) + } + func testKeychainStoresWholePairScopedToServerAndRemovesIt() throws { + let service = "org.spaceworks.tests.\(UUID().uuidString)" + let vault = TokenVault(origin: try ServerOrigin("https://spaceworks.example"), service: service) + let other = TokenVault(origin: try ServerOrigin("https://another.example"), service: service) + defer { try? vault.store(nil) } + try vault.store(TokenPair(access: "a1", refresh: "r1")) + try vault.store(TokenPair(access: "a2", refresh: "r2")) + XCTAssertEqual(try vault.load(), TokenPair(access: "a2", refresh: "r2")); XCTAssertNil(try other.load()) + try vault.store(nil); XCTAssertNil(try vault.load()) + } + func testPersistenceFailurePreventsRefreshRetryAndClearsPair() async throws { + let storage = MemoryTokens(TokenPair(access: "old", refresh: "old-refresh")); storage.failWrites = true + let coordinator = RefreshCoordinator(pair: try storage.load(), persist: { try storage.store($0) }, rotate: { _ in TokenPair(access: "new", refresh: "new-refresh") }) + do { _ = try await coordinator.recover(rejectedAccess: "old"); XCTFail("Expected storage failure") } catch {} + let current = await coordinator.current(); XCTAssertNil(current) + } +} diff --git a/SpaceWorksUITests/MainFlowTests.swift b/SpaceWorksUITests/MainFlowTests.swift index 3a4a404..13bc806 100644 --- a/SpaceWorksUITests/MainFlowTests.swift +++ b/SpaceWorksUITests/MainFlowTests.swift @@ -1,64 +1,74 @@ import XCTest final class MainFlowTests: XCTestCase { - override func setUp() { continueAfterFailure = false } + override func setUp() { continueAfterFailure = false; XCUIDevice.shared.orientation = .portrait } private func launch(extra: [String] = []) -> XCUIApplication { let app = XCUIApplication() app.launchArguments = ["--ui-testing"] + extra app.launch() return app } - func testConnectToShellAndAllMemberTabs() { - let app = launch() - XCTAssertTrue(app.staticTexts["Connect to your Space Works server"].waitForExistence(timeout: 10)) + private func connect(_ app: XCUIApplication) { + XCTAssertTrue(app.textFields["server-url"].waitForExistence(timeout: 10)) + app.swipeUp() app.textFields["server-url"].tap() - app.textFields["server-url"].typeText("https://ui-test.spaceworks.invalid") - app.buttons["connect"].tap() - XCTAssertTrue(app.staticTexts["Sign-in will be available in the next phase."].waitForExistence(timeout: 10)) - attach(app, "Home sign-in placeholder") - for name in ["Home", "Inventory", "Activity", "Inbox", "Profile"] { - app.tabBars.buttons[name].tap() - XCTAssertTrue(app.staticTexts["Nothing here yet."].exists) + app.textFields["server-url"].typeText("https://ui-test.spaceworks.invalid\n") + XCTAssertTrue(app.textFields["username"].waitForExistence(timeout: 10)) + } + private func credentials(_ app: XCUIApplication) { + app.textFields["username"].tap(); app.textFields["username"].typeText("maker") + app.secureTextFields["password"].tap(); app.secureTextFields["password"].typeText("fixture-password\n") + app.swipeUp() + app.buttons["sign-in"].tap() + } + private func selectSpace(_ app: XCUIApplication) { + XCTAssertTrue(app.buttons["makerspace-7"].waitForExistence(timeout: 10)) + app.buttons["makerspace-7"].tap() + } + private func tab(_ app: XCUIApplication, _ title: String) { + if app.tabBars.buttons[title].exists { app.tabBars.buttons[title].tap(); return } + let item = app.descendants(matching: .any)["nav-\(title)"].firstMatch + if !item.isHittable { + let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'sidebar'")).firstMatch + if sidebar.exists { sidebar.tap() } } - XCTAssertFalse(app.tabBars.buttons["Staff"].exists) - attach(app, "Profile and member tabs") - app.buttons["Change server"].tap() - XCTAssertTrue(app.textFields["server-url"].exists) - attach(app, "Server settings") + XCTAssertTrue(item.waitForExistence(timeout: 5)); item.tap() + } + func testPasswordMakerspaceSwitchProfileAndSignOut() { + let app = launch(); connect(app); attach(app, "Sign in portrait") + credentials(app); selectSpace(app) + XCTAssertTrue(app.staticTexts["Welcome back, Maker."].waitForExistence(timeout: 10)) + attach(app, "Home member portrait") + XCUIDevice.shared.orientation = .landscapeLeft + for name in ["Inventory", "Activity", "Inbox", "Profile"] { tab(app, name) } + XCTAssertTrue(app.buttons["Sign out"].waitForExistence(timeout: 5)) + attach(app, "Profile landscape") + app.buttons["Sign out"].tap() + XCTAssertTrue(app.textFields["username"].waitForExistence(timeout: 10)) } - func testInvalidURLAndServerFailureRemainReviewable() { + func testMFAContinuesToMakerspaceSelection() { + let app = launch(extra: ["--mfa"]); connect(app); credentials(app) + XCTAssertTrue(app.textFields["mfa-code"].waitForExistence(timeout: 10)) + app.textFields["mfa-code"].tap(); app.textFields["mfa-code"].typeText("123456\n") + app.buttons["verify"].tap(); selectSpace(app) + XCTAssertTrue(app.staticTexts["Welcome back, Maker."].waitForExistence(timeout: 10)) + } + func testServerFailureRemainsReviewable() { let app = launch(extra: ["--health-unavailable"]) - app.textFields["server-url"].tap() - app.textFields["server-url"].typeText("https://ui-test.spaceworks.invalid") - app.buttons["connect"].tap() + app.textFields["server-url"].tap(); app.textFields["server-url"].typeText("https://ui-test.spaceworks.invalid\n") XCTAssertTrue(app.staticTexts["Try again later."].waitForExistence(timeout: 10)) - XCTAssertTrue(app.textFields["server-url"].exists) - XCTAssertFalse(app.tabBars.buttons["Staff"].exists) attach(app, "Server failure") } - func testDarkModeAndLargestDynamicType() { + func testDarkModeLargestTextPortraitAndLandscape() { let app = launch(extra: ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]) - app.buttons["appearance"].tap() - XCTAssertTrue(app.buttons["Light mode"].exists) - XCTAssertTrue(app.textFields["server-url"].exists) - app.swipeUp() - XCTAssertTrue(app.buttons["connect"].isHittable) - attach(app, "Dark mode and large text") - app.textFields["server-url"].tap() - app.textFields["server-url"].typeText("https://ui-test.spaceworks.invalid\n") - XCTAssertTrue(app.staticTexts["Sign in"].waitForExistence(timeout: 10)) - app.swipeUp() - XCTAssertTrue(app.staticTexts["Nothing here yet."].isHittable) - attach(app, "Home with largest text") - for name in ["Inventory", "Activity", "Inbox", "Profile"] { - app.tabBars.buttons[name].tap() - XCTAssertTrue(app.staticTexts["Nothing here yet."].isHittable) - } + app.buttons["appearance"].tap(); connect(app) + app.swipeUp(); attach(app, "Sign in largest text dark portrait") + XCUIDevice.shared.orientation = .landscapeLeft + app.swipeDown(); XCTAssertTrue(app.textFields["username"].exists) + app.swipeUp(); XCTAssertTrue(app.buttons["sign-in"].exists) + attach(app, "Sign in largest text dark landscape") } private func attach(_ app: XCUIApplication, _ name: String) { - let item = XCTAttachment(screenshot: app.screenshot()) - item.name = name - item.lifetime = .keepAlways - add(item) + let item = XCTAttachment(screenshot: app.screenshot()); item.name = name; item.lifetime = .keepAlways; add(item) } } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 04b9728..f22d6fa 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,30 @@ # Phase notes +## A1 Sign-in — implementation checkpoint (2026-10-16) + +Add generated password/attestation, `/auth/me`, configuration, native MFA verify/enroll/confirm and +logout operations. Store the whole token pair in a server-scoped, non-synchronizing, device-only +Keychain item. Refresh persists synchronously before any waiting request can retry. Logout clears +local credentials even if remote revocation cannot be confirmed. Foreground identity refresh clears +revoked sessions; superadmins are refused. Only direct memberships reach the switcher; staff authority +remains closed until backend 1a. Real devices use a fresh App Attest key and SHA-256 challenge; +Debug simulators use the PRD's development proof. Apple provisioning remains an owner dependency. + +The native flow includes password visibility, MFA enrollment QR/manual secret, recovery-code +acknowledgement, makerspace selection, profile/sign-out, constrained forms, iPad sidebar and phone +tabs, portrait/landscape and semantic light/dark styling. CI is the compiler; this phase is not +complete until its new auth/network/UI tests pass. Local generation's six tests and source lint pass. + +Parity: Email or username, Password, Sign in; Verify your sign-in / Set up two-step sign-in; +Authenticator or recovery code, Verify, Back to sign in; I saved my codes. Continue; +Choose a makerspace; You haven’t joined a makerspace yet.; Profile, Sign out, Change server. +Native MFA completion requires access, refresh and a device grant, rejecting browser-only completion. +The original contract lacks native provider/version configuration, OIDC device callback delivery and +staff authority. These remain tracked backend requests, not invented payloads. The owner deferred +the backend and requested isolated Debug demo flows for planned features; see the implementation plan +and local Docker evidence. All documented JSON request variants are mechanically selected for the +build projection; every source path, schema, field and response stays unchanged. + ## A0 Base (2026-10-16) Scaffold iOS 17+ SwiftUI app from XcodeGen and a local SPM API package. Pin the public design submodule to `fd28f825086a2cac7fa297881f1860b9d332db69`. Generate all light/dark semantic color assets, Swift color accessors, branding config and opaque icon from `branding/brand.json`. Keep the owner-supplied contract unchanged and generate only A0 health/refresh operations and dependent types at build time. diff --git a/docs/IMPLEMENTATION-PLAN.md b/docs/IMPLEMENTATION-PLAN.md new file mode 100644 index 0000000..d0ddef3 --- /dev/null +++ b/docs/IMPLEMENTATION-PLAN.md @@ -0,0 +1,31 @@ +# Complete native app execution plan + +The owner extended the PRD scope on 2026-10-16 to include tablets and requested the remaining +phases in one batch on `dev`. The original PRD remains unchanged. Each phase has generated-client +API coverage, state tests, main-flow UI tests, green macOS CI, commits and a parity note before the +next independent phase. The owner merges the final `dev` → `main` PR. + +| Phase | Deliverable | Gate | +|---|---|---| +| A1 | Device password/attestation, MFA, atomic Keychain rotation, restore/logout, direct-membership switcher | Native provider configuration, authority and missing social contracts become backend requests | +| A2 | Dashboard, inventory/search/detail, requests/loans, bookings/events, card, inbox | Authenticated generated client and validated makerspace | +| A3 | Hardware requests, renewal, booking, registration/ticket, profile/preferences | Server-confirmed write results; no local availability or permission calculation | +| A4 | APNs registration and validated in-app routing | Push v2 contract/server link files and owner APNs provisioning | +| A5 | Server-selected payment SDK, status reconciliation and documents | Supported vendor contract, owner publishable configuration | +| A6 | Permission/authority-gated staff requests and evidence/scanning | Native staff authority contract and real-camera validation | +| A7 | Sessions/membership/account deletion, privacy/accessibility/localization | Account deletion contract; store privacy inputs | +| A8 | White-label release guide, archive/TestFlight/store package | Owner signing/store access, reviewer account and policy URLs | + +## Device and state matrix + +Use compact stacked cards and independent tab navigation on phones. Use a persistent sidebar and +readable constrained content/list-detail on iPads. Avoid stretching forms to tablet width. The CI +matrix covers a small iPhone, common/large iPhone, iPad mini and large iPad, with landscape UI flows +and largest Dynamic Type in light/dark. API stubs remain deterministic; local Docker integration +evidence is recorded separately from simulator UI evidence. Real App Attest, camera, APNs and +payment-provider tests require configured devices/accounts and must never be described as passed +on a simulator. + +The owner subsequently deferred the app backend and requested complete flow/UI coverage now. +Unsupported planned features will have explicit DEBUG-only in-memory demo journeys and production +integration seams; they will not invent endpoints, bypass authority or claim server verification. diff --git a/docs/LOCAL-DOCKER-VALIDATION.md b/docs/LOCAL-DOCKER-VALIDATION.md new file mode 100644 index 0000000..54e36c0 --- /dev/null +++ b/docs/LOCAL-DOCKER-VALIDATION.md @@ -0,0 +1,46 @@ +# Local Docker integration evidence + +The owner selected local Docker as the integration environment on 2026-10-16. +The existing development stack was restarted without deleting volumes, changing backend source, +or replacing its database. Its API is published on port 8000; the web flow is on port 5000. + +| Client | Debug origin | +|---|---| +| Windows host | http://localhost:8000 | +| Android emulator | http://10.0.2.2:8000 | +| iOS Simulator on a Mac | A loopback proxy to this Docker host is required | + +GitHub-hosted macOS runners cannot reach this Windows machine's localhost. Their XCTest/UI tests +use deterministic contract fixtures. Those results do not establish live Docker integration. +Release builds must continue to require HTTPS. + +## Observed API results + +| Probe | Result | Meaning | +|---|---|---| +| GET /api/v1/health/ | 200, status=ok | Local API is reachable | +| GET /api/v1/config | 200 | Public configuration is reachable | +| POST /api/v1/auth/device/attestation-challenge | 503, attestation_unavailable | Native session integration is blocked by local server configuration | + +The challenge probe used platform=android, app_id=org.spaceworks.app and environment=development, +as documented by the supplied contract. The container has DEBUG=True and no +DEVICE_ATTESTATION_APPS environment entry. The public environment reference lists that setting +with an empty default but does not specify the allowlist format. Do not bypass attestation or +describe sign-in, refresh, authenticated reads/writes or logout as live-verified until the +owner supplies the development configuration and the challenge succeeds. + +The running stack uses the existing design-revamp worktree. Its schema file differs from the +owner-supplied mobile snapshot. This is not authority to silently replace api/openapi.json: +the apps remain pinned to the supplied contract, and any observed response mismatch must be +reported before updating the snapshot. + +## Required remaining evidence + +After configuration: native member/manager login, MFA if enabled, me/membership filtering, +refresh rotation and revoked-session handling, all implemented generated read/write operations, +logout and restoration after process restart. Use isolated development fixtures, preserve +existing records, revoke created device grants, and keep credentials/tokens out of logs and Git. + +Production SDK attestation, APNs/FCM delivery, social OAuth, payment-provider completion, +real-camera staff evidence and signed release builds need their actual provider/device setup. +They are separate from deterministic UI/state tests. diff --git a/project.yml b/project.yml index 7060f16..79e92c8 100644 --- a/project.yml +++ b/project.yml @@ -14,6 +14,7 @@ settings: MARKETING_VERSION: 0.1.0 GENERATE_INFOPLIST_FILE: YES ENABLE_USER_SCRIPT_SANDBOXING: NO + TARGETED_DEVICE_FAMILY: '1,2' packages: SpaceWorksAPI: path: Packages/SpaceWorksAPI @@ -30,8 +31,10 @@ targets: configs: Debug: INFOPLIST_FILE: SpaceWorks/Generated/Info-Debug.plist + CODE_SIGN_ENTITLEMENTS: SpaceWorks/Generated/Attestation-Debug.entitlements Release: INFOPLIST_FILE: SpaceWorks/Generated/Info-Release.plist + CODE_SIGN_ENTITLEMENTS: SpaceWorks/Generated/Attestation-Release.entitlements base: PRODUCT_BUNDLE_IDENTIFIER: $(BRAND_BUNDLE_ID) DEVELOPMENT_TEAM: $(BRAND_TEAM_ID) diff --git a/scripts/generate.py b/scripts/generate.py index 68e3ad4..223f123 100644 --- a/scripts/generate.py +++ b/scripts/generate.py @@ -50,10 +50,14 @@ def generate(raster=True): + "\n}\n") for mode in ["Debug", "Release"]: info = {"CFBundleDisplayName": brand["app_name"], "UILaunchScreen": {}, "UIApplicationSceneManifest": {"UIApplicationSupportsMultipleScenes": False}} + info["UISupportedInterfaceOrientations"] = ["UIInterfaceOrientationPortrait", "UIInterfaceOrientationLandscapeLeft", "UIInterfaceOrientationLandscapeRight"] + info["UISupportedInterfaceOrientations~ipad"] = info["UISupportedInterfaceOrientations"] + ["UIInterfaceOrientationPortraitUpsideDown"] if mode == "Debug": info["NSAppTransportSecurity"] = {"NSAllowsLocalNetworking": True} target = ROOT / f"SpaceWorks/Generated/Info-{mode}.plist" target.write_bytes(plistlib.dumps(info)) + (ROOT / f"SpaceWorks/Generated/Attestation-{mode}.entitlements").write_bytes(plistlib.dumps({ + "com.apple.developer.devicecheck.appattest-environment": "development" if mode == "Debug" else "production"})) tokens = json.loads(inside(brand["theme_tokens"]).read_text()) if set(tokens["light"]) != set(tokens["dark"]): raise ValueError("Light and dark semantic roles must match") @@ -94,8 +98,13 @@ def generate(raster=True): # JSON representation so that DeviceRefresh remains a generated Codable object. No fields, # operations or response schemas are changed, and the authoritative source stays untouched. document = json.loads(schema.read_bytes()) - content = document["paths"]["/api/v1/auth/device/refresh"]["post"]["requestBody"]["content"] - document["paths"]["/api/v1/auth/device/refresh"]["post"]["requestBody"]["content"] = {"application/json": content["application/json"]} + for operations in document["paths"].values(): + for operation in operations.values(): + if not isinstance(operation, dict): + continue + content = operation.get("requestBody", {}).get("content", {}) + if "application/json" in content: + operation["requestBody"]["content"] = {"application/json": content["application/json"]} write(ROOT / "Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi.json", json.dumps(document, ensure_ascii=False, indent=2) + "\n") diff --git a/scripts/tests/test_generation.py b/scripts/tests/test_generation.py index 3056333..923ff1f 100644 --- a/scripts/tests/test_generation.py +++ b/scripts/tests/test_generation.py @@ -18,9 +18,14 @@ def test_authoritative_contract_is_unchanged_and_staged_for_plugin(self): self.assertEqual(hashlib.sha256(source).hexdigest(), json.loads((ROOT / "api/provenance.json").read_text())["sha256"]) staged = json.loads((ROOT / "Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi.json").read_bytes()) expected = json.loads(source) - content = expected["paths"]["/api/v1/auth/device/refresh"]["post"]["requestBody"]["content"] - expected["paths"]["/api/v1/auth/device/refresh"]["post"]["requestBody"]["content"] = {"application/json": content["application/json"]} - self.assertEqual(staged, expected, "Only refresh's documented JSON representation may be selected; all models must remain unchanged") + for operations in expected["paths"].values(): + for operation in operations.values(): + if not isinstance(operation, dict): + continue + content = operation.get("requestBody", {}).get("content", {}) + if "application/json" in content: + operation["requestBody"]["content"] = {"application/json": content["application/json"]} + self.assertEqual(staged, expected, "Only documented JSON request representations may be selected; all models remain unchanged") self.assertEqual(len(json.loads(source)["paths"]), 635) def test_brand_config_drives_project_display_name_bundle_and_icon(self): From 37d532aef5f896bcae4a716bc873aa6bd46bf461 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:34:21 +0530 Subject: [PATCH 03/98] feat(a1): add isolated auth demo and adaptive device validation Co-Authored-By: Codex --- .github/workflows/ios.yml | 15 +- .gitignore | 3 + SpaceWorks/AuthState.swift | 9 +- SpaceWorks/AuthView.swift | 15 +- SpaceWorks/DemoMode.swift | 159 ++++++++++++++++++ SpaceWorks/Platform/NativeNotifications.swift | 122 ++++++++++++++ SpaceWorks/Platform/NativeScanner.swift | 132 +++++++++++++++ SpaceWorks/RootView.swift | 29 +++- SpaceWorksTests/AuthTests.swift | 20 +++ .../NativeNotificationsTests.swift | 41 +++++ SpaceWorksTests/NetworkingTests.swift | 2 +- SpaceWorksUITests/MainFlowTests.swift | 15 ++ docs/CHANGELOG.md | 7 + docs/LOCAL-DOCKER-VALIDATION.md | 3 + scripts/device-matrix.py | 62 +++++++ scripts/generate.py | 8 + scripts/tests/test_device_matrix.py | 58 +++++++ 17 files changed, 684 insertions(+), 16 deletions(-) create mode 100644 SpaceWorks/DemoMode.swift create mode 100644 SpaceWorks/Platform/NativeNotifications.swift create mode 100644 SpaceWorks/Platform/NativeScanner.swift create mode 100644 SpaceWorksTests/NativeNotificationsTests.swift create mode 100644 scripts/device-matrix.py create mode 100644 scripts/tests/test_device_matrix.py diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 312a7d5..eb9fdcd 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -2,6 +2,7 @@ name: iOS on: push: pull_request: + workflow_dispatch: permissions: contents: read concurrency: @@ -11,6 +12,12 @@ jobs: build-and-test: runs-on: macos-15 timeout-minutes: 45 + name: Build and test (${{ matrix.device }}) + strategy: + fail-fast: false + max-parallel: 2 + matrix: + device: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["compact","phone","large","tablet","tablet-landscape","large-tablet"]' || '["phone","tablet"]') }} steps: - uses: actions/checkout@v4 with: @@ -23,10 +30,9 @@ jobs: python3 -m unittest discover -s scripts/tests -v python3 scripts/lint.py xcodegen generate - - name: Select available iPhone simulator + - name: Select phone or tablet simulator run: | - DEVICE=$(xcrun simctl list devices available -j | python3 -c 'import json,sys; d=json.load(sys.stdin); print(next(x["udid"] for r,v in d["devices"].items() if "iOS" in r for x in v if "iPhone" in x["name"]))') - echo "SIMULATOR_ID=$DEVICE" >> "$GITHUB_ENV" + python3 scripts/device-matrix.py '${{ matrix.device }}' - name: Compile and test on simulator run: | set -o pipefail @@ -41,11 +47,12 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: ios-validation + name: ios-validation-${{ matrix.device }} path: | TestResults.xcresult TestAttachments build.log + validation-device.json DerivedData/Build/Intermediates.noindex/BuildToolPluginIntermediates/**/GeneratedSources/*.swift SpaceWorks.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved retention-days: 7 diff --git a/.gitignore b/.gitignore index a00de35..50b653c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ google-services.json *.mobileprovision *.xcresult/ __pycache__/ +/validation-device.json +/TestAttachments/ +/build.log diff --git a/SpaceWorks/AuthState.swift b/SpaceWorks/AuthState.swift index fda206f..ae6f90b 100644 --- a/SpaceWorks/AuthState.swift +++ b/SpaceWorks/AuthState.swift @@ -132,7 +132,7 @@ final class AuthState { defer { busy = false } do { try await loadIdentity() } catch { fail(error) - if isUnauthorized(error) { try? await service.refresh.install(nil); clear() } + if isUnauthorized(error) { try? await service.refresh.install(nil); await clear() } } } func signOut() async { @@ -143,9 +143,9 @@ final class AuthState { do { try await service.signOut() } catch { revocationFailed = true } do { try await service.refresh.install(nil) - clear() + await clear() if revocationFailed { error = "Signed out on this device. Server revocation could not be confirmed." } - } catch { clear(); fail(error) } + } catch { await clear(); fail(error) } } private func accept(_ pair: TokenPair) async throws { guard !pair.access.isEmpty, !pair.refresh.isEmpty else { @@ -171,7 +171,8 @@ final class AuthState { selectedID = selection stage = .signedIn } - private func clear() { + private func clear() async { + try? await service.makerspace.select(nil, activeMemberships: []) user = nil; selectedID = nil; mfa = nil; enrollment = nil; recoveryCodes = [] password = ""; code = ""; settings.removeObject(forKey: "makerspace-selection"); stage = .signIn } diff --git a/SpaceWorks/AuthView.swift b/SpaceWorks/AuthView.swift index 21d6fa9..000e7b6 100644 --- a/SpaceWorks/AuthView.swift +++ b/SpaceWorks/AuthView.swift @@ -54,13 +54,14 @@ struct SignInView: View { @Bindable var auth: AuthState let state: AppState @State private var showPassword = false + @FocusState private var passwordFocused: Bool @Environment(\.dynamicTypeSize) private var typeSize var body: some View { GeometryReader { geometry in ScrollView { HStack(alignment: .center, spacing: 40) { VStack(alignment: .leading, spacing: 24) { - Text(Brand.appName).font(.headline).foregroundStyle(Color.swAccentInk) + BrandWordmark() Text("Welcome back.").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) Text("Your makerspace, in your pocket.").foregroundStyle(Color.swMuted) SWCard { @@ -78,7 +79,8 @@ struct SignInView: View { Group { if showPassword { TextField("Password", text: $auth.password) } else { SecureField("Password", text: $auth.password) } - }.textContentType(.password).accessibilityIdentifier("password") + }.textContentType(.password).focused($passwordFocused).submitLabel(.done) + .onSubmit { passwordFocused = false }.accessibilityIdentifier("password") Button { showPassword.toggle() } label: { Image(systemName: showPassword ? "eye.slash" : "eye") } .accessibilityLabel(showPassword ? "Hide password" : "Show password").frame(minWidth: 44, minHeight: 44) } @@ -116,6 +118,15 @@ struct SignInView: View { } } +struct BrandWordmark: View { + var body: some View { + if Brand.appName == "Space Works" { + Image("Wordmark").resizable().scaledToFit().frame(maxWidth: 180, maxHeight: 64) + .padding(.vertical, 16).accessibilityLabel("Space Works") + } else { Text(Brand.appName).font(.headline).foregroundStyle(Color.swAccentInk) } + } +} + struct MFAView: View { @Bindable var auth: AuthState var body: some View { diff --git a/SpaceWorks/DemoMode.swift b/SpaceWorks/DemoMode.swift new file mode 100644 index 0000000..ffeee6a --- /dev/null +++ b/SpaceWorks/DemoMode.swift @@ -0,0 +1,159 @@ +#if DEBUG +import SwiftUI +import Observation + +@MainActor @Observable +final class DemoSession { + enum Role: String, CaseIterable { case member = "Member", staff = "Staff" } + enum Scenario: String, CaseIterable { case password = "Password", mfa = "MFA", enrollment = "Enrollment" } + enum Stage: Equatable { case welcome, signIn, mfa, recovery, makerspace, shell } + var role = Role.member + var scenario = Scenario.password + var stage = Stage.welcome + var username = "" + var password = "" + var code = "" + var error: String? + var selectedSpace = 0 + let spaces = ["Fab Lab Kochi", "Maker Space Bengaluru"] + var name: String { role == .member ? "Meera Nair" : "Arjun Rao" } + func signIn() { + guard !username.isEmpty, !password.isEmpty else { error = "Enter your email or username and password."; return } + error = nil; password = ""; stage = scenario == .password ? .makerspace : .mfa + } + func verify() { + guard code == "123456" || code == "sample-recovery" else { error = "That code didn’t work. Try again."; return } + code = ""; error = nil; stage = scenario == .enrollment ? .recovery : .makerspace + } + func reset() { username = ""; password = ""; code = ""; error = nil; selectedSpace = 0; stage = .welcome } +} + +struct DemoRoot: View { + let exit: () -> Void + @State private var session = DemoSession() + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Demo mode · sample data").font(.caption.bold()) + Spacer() + Button("Exit demo") { session.reset(); exit() }.frame(minHeight: 44) + }.padding(.horizontal, 20).foregroundStyle(Color.swOnEmphasis).background(Color.swEmphasis) + if session.stage == .shell { DemoShell(session: session) } + else { DemoAuthView(session: session) } + }.background(Color.swBg) + } +} + +struct DemoAuthView: View { + @Bindable var session: DemoSession + @FocusState private var passwordFocused: Bool + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + BrandWordmark() + SWCard { + switch session.stage { + case .welcome: + Text("Make yourself at home.").font(.largeTitle.bold()) + Text("Explore sample makerspaces and complete app flows. Nothing is sent to a server.").foregroundStyle(Color.swMuted) + Picker("Demo role", selection: $session.role) { ForEach(DemoSession.Role.allCases, id: \.self) { Text($0.rawValue) } } + .pickerStyle(.segmented).accessibilityIdentifier("demo-role") + Picker("Sign-in preview", selection: $session.scenario) { ForEach(DemoSession.Scenario.allCases, id: \.self) { Text($0.rawValue) } } + Button("Continue as \(session.role.rawValue.lowercased())") { session.stage = .signIn }.buttonStyle(SWPrimary()) + case .signIn: + Text("Welcome back.").font(.largeTitle.bold()) + Text("Your makerspace, in your pocket.").foregroundStyle(Color.swMuted) + Text("Email or username").font(.headline) + TextField("Email or username", text: $session.username).textInputAutocapitalization(.never) + .autocorrectionDisabled().demoField().accessibilityIdentifier("demo-username") + Text("Password").font(.headline) + SecureField("Password", text: $session.password).focused($passwordFocused) + .submitLabel(.done).onSubmit { passwordFocused = false } + .demoField().accessibilityIdentifier("demo-password") + Text("Use any email and password in this demo.").font(.caption).foregroundStyle(Color.swMuted) + if let error = session.error { SWError(message: error) } + Button("Sign in") { session.signIn() }.buttonStyle(SWPrimary()).accessibilityIdentifier("demo-sign-in") + Button("Back") { session.reset() }.frame(minHeight: 44) + case .mfa: + Text(session.scenario == .enrollment ? "Set up two-step sign-in" : "Verify your sign-in").font(.largeTitle.bold()) + if session.scenario == .enrollment { + QRCodeView(value: "otpauth://totp/SpaceWorks:demo?secret=JBSWY3DPEHPK3PXP&issuer=SpaceWorks") + Text("Can't scan? Enter this key").font(.headline) + Text("JBSWY3DPEHPK3PXP").font(.body.monospaced()).textSelection(.enabled) + } + Text("Authenticator or recovery code").font(.headline) + TextField("Authenticator or recovery code", text: $session.code).demoField() + .textContentType(.oneTimeCode).accessibilityIdentifier("demo-mfa-code") + Text("Use 123456 or sample-recovery in this demo.").font(.caption).foregroundStyle(Color.swMuted) + if let error = session.error { SWError(message: error) } + Button("Verify") { session.verify() }.buttonStyle(SWPrimary()) + Button("Back to sign in") { session.stage = .signIn; session.error = nil }.frame(minHeight: 44) + case .recovery: + Text("Save your recovery codes").font(.largeTitle.bold()) + Text("Sample codes for this demo. Real codes appear once after enrollment.").foregroundStyle(Color.swMuted) + Text("sample-recovery\nsample-backup").font(.body.monospaced()).textSelection(.enabled) + Button("I saved my codes. Continue") { session.stage = .makerspace }.buttonStyle(SWPrimary()) + case .makerspace: + Text("Choose a makerspace").font(.largeTitle.bold()) + ForEach(session.spaces.indices, id: \.self) { index in + Button { session.selectedSpace = index; session.stage = .shell } label: { + Label(session.spaces[index], systemImage: "building.2").frame(maxWidth: .infinity, minHeight: 52, alignment: .leading) + }.buttonStyle(.plain).accessibilityIdentifier("demo-space-\(index)") + } + case .shell: EmptyView() + } + } + }.padding(24).frame(maxWidth: 600).frame(maxWidth: .infinity) + }.background(Color.swBg).scrollDismissesKeyboard(.interactively) + } +} + +struct DemoShell: View { + let session: DemoSession + @Environment(\.horizontalSizeClass) private var sizeClass + @State private var selected = AppTab.home + var body: some View { + if sizeClass == .regular { + NavigationSplitView { + List(AppTab.allCases) { tab in + Button(tab.rawValue) { selected = tab }.frame(minHeight: 44).accessibilityIdentifier("nav-\(tab.rawValue)") + .listRowBackground(selected == tab ? Color.swSidebarActive : Color.swSidebar) + .foregroundStyle(selected == tab ? Color.swOnAccent : Color.swInk) + }.scrollContentBackground(.hidden).background(Color.swSidebar).navigationTitle(Brand.appName) + } detail: { NavigationStack { DemoDestination(session: session, tab: selected) } } + } else { + TabView(selection: $selected) { + ForEach(AppTab.allCases) { tab in + NavigationStack { DemoDestination(session: session, tab: tab) } + .tabItem { Label(tab.rawValue, systemImage: tab.symbol) }.tag(tab) + } + } + } + } +} +struct DemoDestination: View { + let session: DemoSession + let tab: AppTab + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + Text("Welcome back, \(session.name).").font(.largeTitle.bold()) + Text(session.spaces[session.selectedSpace]).foregroundStyle(Color.swMuted) + SWCard { + if tab == .profile { + Text(session.role.rawValue).font(.headline) + Button("Sign out") { session.reset() }.frame(minHeight: 44) + } else { Text("Nothing here yet.") } + } + }.padding(24).frame(maxWidth: 960).frame(maxWidth: .infinity) + }.background(Color.swBg).navigationTitle(tab.rawValue) + } +} +private extension View { + func demoField() -> some View { + padding(14).frame(minHeight: 48).foregroundStyle(Color.swInk) + .background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)) + } +} +#endif diff --git a/SpaceWorks/Platform/NativeNotifications.swift b/SpaceWorks/Platform/NativeNotifications.swift new file mode 100644 index 0000000..5a47e57 --- /dev/null +++ b/SpaceWorks/Platform/NativeNotifications.swift @@ -0,0 +1,122 @@ +import CoreFoundation +import Foundation +import Observation +import UIKit +import UserNotifications + +/// Notification content is a routing hint. The caller must revalidate membership and fetch API data. +struct NotificationHint: Equatable { + let makerspaceID: Int + let version: Int + let eventID: String? + let inboxRowID: Int? + let relativeLink: String? + + init?(payload: [AnyHashable: Any]) { + guard let id = Self.positiveID(payload["makerspace_id"]) else { return nil } + makerspaceID = id + version = Int(Self.string(payload["v"]) ?? "1") ?? 0 + eventID = version == 2 ? Self.string(payload["event_id"]).flatMap { $0.count <= 256 ? $0 : nil } : nil + inboxRowID = version == 2 ? Self.positiveID(payload["inbox_row_id"]) : nil + if version == 2, let raw = Self.string(payload["link"]), raw.utf8.count <= 2048, + !raw.contains("\\"), !raw.contains("%"), + raw.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }), + let link = URLComponents(string: raw), link.scheme == nil, link.host == nil, + link.query == nil, link.fragment == nil, link.path.hasPrefix("/"), !link.path.hasPrefix("//"), + !link.path.split(separator: "/").contains("..") { + relativeLink = link.path + } else { relativeLink = nil } + } + + private static func string(_ value: Any?) -> String? { + if let text = value as? String { return text } + if let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() { return number.stringValue } + return nil + } + + private static func positiveID(_ value: Any?) -> Int? { + guard let text = string(value), let number = Int(text), number > 0 else { return nil } + return number + } +} + +@MainActor @Observable +final class NativeNotifications { + static let shared = NativeNotifications() + private(set) var authorization: UNAuthorizationStatus = .notDetermined + private(set) var token: String? + private(set) var error: String? + var onTokenChanged: ((String) -> Void)? + var onTap: ((NotificationHint) -> Void)? + var mayPresent: ((NotificationHint) -> Bool)? + + func requestPermission() async { + error = nil + do { + let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) + authorization = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus + if granted { UIApplication.shared.registerForRemoteNotifications() } + } catch { self.error = "Notifications could not be enabled. Try again in Settings." } + } + + func refreshAuthorization() async { + authorization = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus + if authorization == .authorized || authorization == .provisional || authorization == .ephemeral { + UIApplication.shared.registerForRemoteNotifications() + } + } + + func didRegister(_ data: Data) { + let value = data.map { String(format: "%02x", $0) }.joined() + guard !value.isEmpty else { return } + token = value + error = nil + onTokenChanged?(value) + } + + func registrationFailed() { error = "Push registration is unavailable. Your inbox is still available." } + + /// Call after removing server registration IDs. No prior user's notification routing survives logout. + func reset() { + UIApplication.shared.unregisterForRemoteNotifications() + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + token = nil + error = nil + onTokenChanged = nil + onTap = nil + mayPresent = nil + } +} + +final class NativeNotificationDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + UNUserNotificationCenter.current().delegate = self + return true + } + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + Task { @MainActor in NativeNotifications.shared.didRegister(deviceToken) } + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + Task { @MainActor in NativeNotifications.shared.registrationFailed() } + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + let hint = NotificationHint(payload: response.notification.request.content.userInfo) + Task { @MainActor in + if let hint { NativeNotifications.shared.onTap?(hint) } + completionHandler() + } + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + let hint = NotificationHint(payload: notification.request.content.userInfo) + Task { @MainActor in + let allowed = hint.map { NativeNotifications.shared.mayPresent?($0) == true } ?? false + completionHandler(allowed ? [.banner, .sound] : []) + } + } +} diff --git a/SpaceWorks/Platform/NativeScanner.swift b/SpaceWorks/Platform/NativeScanner.swift new file mode 100644 index 0000000..bdcfc41 --- /dev/null +++ b/SpaceWorks/Platform/NativeScanner.swift @@ -0,0 +1,132 @@ +import AVFoundation +import SwiftUI +import VisionKit + +/// Camera output is an opaque value for server-side resolution, never an automatically opened URL. +struct NativeQRScanner: View { + let title: String + let onScan: (String) -> Void + @Environment(\.dismiss) private var dismiss + @State private var manual = "" + @State private var cameraReady = false + @State private var message: String? + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Text("Scan the QR code, then review the details before confirming.") + .foregroundStyle(Color.swMuted) + if cameraReady { + ScannerCamera(onScan: complete, onUnavailable: { + cameraReady = false + message = "Camera scanning is unavailable. Enter the code below." + }) + .frame(minHeight: 280, idealHeight: 360, maxHeight: 420) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .accessibilityLabel("QR code camera") + } + if let message { + Label(message, systemImage: "camera") + .foregroundStyle(Color.swMuted) + } + Text("Enter code manually").font(.headline) + TextField("Code", text: $manual) + .textInputAutocapitalization(.never).autocorrectionDisabled() + .textFieldStyle(.roundedBorder).accessibilityIdentifier("scanner-manual-code") + .submitLabel(.go).onSubmit { complete(manual) } + Button { complete(manual) } label: { + Text("Review code").frame(minHeight: 48) + } + .buttonStyle(.borderedProminent).tint(Color.swAccent) + .foregroundStyle(Color.swOnAccent) + .disabled(manual.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .accessibilityIdentifier("scanner-review") + }.padding(24).frame(maxWidth: 640) + } + .frame(maxWidth: .infinity).background(Color.swBg).foregroundStyle(Color.swInk) + .navigationTitle(title).navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } + .task { await prepareCamera() } + } + } + + private func prepareCamera() async { + guard DataScannerViewController.isSupported else { + message = "Camera scanning is unavailable on this device. Enter the code below." + return + } + var allowed = AVCaptureDevice.authorizationStatus(for: .video) == .authorized + if AVCaptureDevice.authorizationStatus(for: .video) == .notDetermined { + allowed = await AVCaptureDevice.requestAccess(for: .video) + } + cameraReady = allowed && DataScannerViewController.isAvailable + if !cameraReady { message = "Camera access is unavailable. Enter the code below, or allow Camera in Settings." } + } + + private func complete(_ value: String) { + let code = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !code.isEmpty, code.utf8.count <= 2048, + code.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) else { + message = "Enter a valid code and try again." + return + } + onScan(code) + dismiss() + } +} + +private struct ScannerCamera: UIViewControllerRepresentable { + let onScan: (String) -> Void + let onUnavailable: () -> Void + + func makeCoordinator() -> Coordinator { Coordinator(onScan: onScan, onUnavailable: onUnavailable) } + + func makeUIViewController(context: Context) -> DataScannerViewController { + let scanner = DataScannerViewController(recognizedDataTypes: [.barcode(symbologies: [.qr])], + qualityLevel: .balanced, recognizesMultipleItems: false, isHighFrameRateTrackingEnabled: false, + isPinchToZoomEnabled: true, isGuidanceEnabled: true, isHighlightingEnabled: true) + scanner.delegate = context.coordinator + do { try scanner.startScanning() } + catch { DispatchQueue.main.async { context.coordinator.onUnavailable() } } + return scanner + } + + func updateUIViewController(_ scanner: DataScannerViewController, context: Context) { + context.coordinator.onScan = onScan + context.coordinator.onUnavailable = onUnavailable + } + + static func dismantleUIViewController(_ scanner: DataScannerViewController, coordinator: Coordinator) { + scanner.stopScanning() + scanner.delegate = nil + } + + final class Coordinator: NSObject, DataScannerViewControllerDelegate { + var onScan: (String) -> Void + var onUnavailable: () -> Void + private var delivered = false + + init(onScan: @escaping (String) -> Void, onUnavailable: @escaping () -> Void) { + self.onScan = onScan + self.onUnavailable = onUnavailable + } + + func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) { + guard !delivered else { return } + for item in addedItems { + if case .barcode(let barcode) = item, let value = barcode.payloadStringValue, !value.isEmpty { + delivered = true + dataScanner.stopScanning() + onScan(value) + return + } + } + } + + func dataScanner(_ dataScanner: DataScannerViewController, becameUnavailableWithError error: DataScannerViewController.ScanningUnavailable) { + dataScanner.stopScanning() + onUnavailable() + } + } +} diff --git a/SpaceWorks/RootView.swift b/SpaceWorks/RootView.swift index 4e8aed9..d9eb137 100644 --- a/SpaceWorks/RootView.swift +++ b/SpaceWorks/RootView.swift @@ -2,13 +2,17 @@ import SwiftUI struct RootView: View { @State private var state = AppState() + #if DEBUG + @State private var exploringDemo = false + #endif var body: some View { Group { - switch state.screen { - case .connect: ConnectView(state: state) - case .shell: - if let origin = state.connectedOrigin { AuthRoot(state: state, origin: origin) } - } + #if DEBUG + if exploringDemo { DemoRoot { exploringDemo = false } } + else { liveContent } + #else + liveContent + #endif } .tint(Color.swAccentInk) .foregroundStyle(Color.swInk) @@ -24,6 +28,21 @@ struct RootView: View { } .task { await state.restore() } } + private var liveContent: some View { + VStack(spacing: 0) { + switch state.screen { + case .connect: ConnectView(state: state) + case .shell: + if let origin = state.connectedOrigin { AuthRoot(state: state, origin: origin) } + } + #if DEBUG + if state.screen == .connect { + Button("Explore demo") { exploringDemo = true }.frame(minHeight: 48).accessibilityIdentifier("explore-demo") + .frame(maxWidth: .infinity).background(Color.swSurface) + } + #endif + } + } } private struct ConnectView: View { diff --git a/SpaceWorksTests/AuthTests.swift b/SpaceWorksTests/AuthTests.swift index 7dd671d..87ba51e 100644 --- a/SpaceWorksTests/AuthTests.swift +++ b/SpaceWorksTests/AuthTests.swift @@ -1,4 +1,5 @@ import XCTest +import Foundation import SpaceWorksAPI @testable import SpaceWorks @@ -117,4 +118,23 @@ final class MemoryTokens: TokenStorage, @unchecked Sendable { do { _ = try await coordinator.recover(rejectedAccess: "old"); XCTFail("Expected storage failure") } catch {} let current = await coordinator.current(); XCTAssertNil(current) } + func testOfflineSignOutStillWipesLocalCredentials() async throws { + let storage = MemoryTokens(TokenPair(access: "test-access", refresh: "test-refresh")) + let auth = state(storage: storage) { request in + if request.url!.path.hasSuffix("/logout") { throw URLError(.notConnectedToInternet) } + return try self.response(request) + } + await auth.restore(); await auth.signOut() + XCTAssertNil(try storage.load()); XCTAssertNil(auth.user); XCTAssertEqual(auth.stage, .signIn) + XCTAssertEqual(auth.error, "Signed out on this device. Server revocation could not be confirmed.") + let makerspace = await auth.service.makerspace.current(); XCTAssertNil(makerspace) + } + func testIsolatedDemoAuthRequiresCredentialsAndRecoveryAcknowledgement() { + let demo = DemoSession(); demo.role = .staff; demo.scenario = .enrollment; demo.stage = .signIn + demo.signIn(); XCTAssertEqual(demo.stage, .signIn); XCTAssertNotNil(demo.error) + demo.username = "arjun"; demo.password = "sample"; demo.signIn(); XCTAssertEqual(demo.stage, .mfa) + demo.code = "000000"; demo.verify(); XCTAssertEqual(demo.stage, .mfa) + demo.code = "123456"; demo.verify(); XCTAssertEqual(demo.stage, .recovery) + demo.reset(); XCTAssertEqual(demo.stage, .welcome); XCTAssertTrue(demo.password.isEmpty) + } } diff --git a/SpaceWorksTests/NativeNotificationsTests.swift b/SpaceWorksTests/NativeNotificationsTests.swift new file mode 100644 index 0000000..aa21363 --- /dev/null +++ b/SpaceWorksTests/NativeNotificationsTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import SpaceWorks + +final class NativeNotificationsTests: XCTestCase { + func testLegacyPayloadUsesMembershipInboxAndIgnoresLink() { + let hint = NotificationHint(payload: ["makerspace_id": "7", "link": "/inventory/123"]) + XCTAssertEqual(hint?.version, 1) + XCTAssertEqual(hint?.makerspaceID, 7) + XCTAssertNil(hint?.relativeLink) + } + + func testVersionTwoCarriesOnlyRelativeHintAndIdentifiers() { + let hint = NotificationHint(payload: ["v": "2", "makerspace_id": 7, "event_id": "event-1", + "inbox_row_id": "9", "link": "/inventory/123", "unknown": "ignored"]) + XCTAssertEqual(hint?.eventID, "event-1") + XCTAssertEqual(hint?.inboxRowID, 9) + XCTAssertEqual(hint?.relativeLink, "/inventory/123") + } + + func testUntrustedLinksFallBackToInbox() { + for link in ["https://evil.invalid", "//evil.invalid", "/../profile", "/%2e%2e/profile", + "/inventory?redirect=https://evil.invalid", "/inventory#secret", "/inventory\\evil", "/bad\ncode"] { + XCTAssertNil(NotificationHint(payload: ["v": "2", "makerspace_id": "7", "link": link])?.relativeLink, link) + } + } + + func testMissingOrInvalidMembershipDoesNotRoute() { + let values: [Any] = ["", "0", "-1", "1.5", true, "9999999999999999999999999999999999999999"] + for value in values { + XCTAssertNil(NotificationHint(payload: ["makerspace_id": value])) + } + XCTAssertNil(NotificationHint(payload: [:])) + } + + func testUnknownVersionRetainsOnlyInboxMembership() { + let hint = NotificationHint(payload: ["v": "99", "makerspace_id": "7", "event_id": "unsafe", "link": "/profile"]) + XCTAssertEqual(hint?.makerspaceID, 7) + XCTAssertNil(hint?.eventID) + XCTAssertNil(hint?.relativeLink) + } +} diff --git a/SpaceWorksTests/NetworkingTests.swift b/SpaceWorksTests/NetworkingTests.swift index b172222..44c8eac 100644 --- a/SpaceWorksTests/NetworkingTests.swift +++ b/SpaceWorksTests/NetworkingTests.swift @@ -181,7 +181,7 @@ private final class LockedCounter: @unchecked Sendable { var value: Int { lock.lock(); defer { lock.unlock() }; return count } } -private func requestBody(_ request: URLRequest) -> Data { +func requestBody(_ request: URLRequest) -> Data { if let data = request.httpBody { return data } guard let stream = request.httpBodyStream else { return Data() } stream.open(); defer { stream.close() } diff --git a/SpaceWorksUITests/MainFlowTests.swift b/SpaceWorksUITests/MainFlowTests.swift index 13bc806..740558d 100644 --- a/SpaceWorksUITests/MainFlowTests.swift +++ b/SpaceWorksUITests/MainFlowTests.swift @@ -68,6 +68,21 @@ final class MainFlowTests: XCTestCase { app.swipeUp(); XCTAssertTrue(app.buttons["sign-in"].exists) attach(app, "Sign in largest text dark landscape") } + func testExploreDemoIsIsolatedAndExitsToServerSetup() { + let app = launch(); app.buttons["explore-demo"].tap() + XCTAssertTrue(app.staticTexts["Demo mode · sample data"].exists) + app.buttons["Continue as member"].tap() + app.textFields["demo-username"].tap(); app.textFields["demo-username"].typeText("meera") + app.secureTextFields["demo-password"].tap(); app.secureTextFields["demo-password"].typeText("sample\n") + app.swipeUp(); app.buttons["demo-sign-in"].tap() + app.buttons["demo-space-0"].tap() + XCTAssertTrue(app.staticTexts["Welcome back, Meera Nair."].waitForExistence(timeout: 5)) + attach(app, "Isolated member demo") + app.buttons["Exit demo"].tap() + XCTAssertTrue(app.textFields["server-url"].exists) + let value = app.textFields["server-url"].value as? String ?? "" + XCTAssertTrue(value.isEmpty || value == "https://spaceworks.example") + } private func attach(_ app: XCUIApplication, _ name: String) { let item = XCTAttachment(screenshot: app.screenshot()); item.name = name; item.lifetime = .keepAlways; add(item) } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f22d6fa..4393e9c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -25,6 +25,13 @@ the backend and requested isolated Debug demo flows for planned features; see th and local Docker evidence. All documented JSON request variants are mechanically selected for the build projection; every source path, schema, field and response stays unchanged. +The Debug-only “Explore demo” entry has a persistent “Demo mode · sample data” banner and exit/reset. +Its member Meera Nair and staff Arjun Rao journeys cover password validation, MFA rejection/success, +enrollment QR/manual secret, recovery-code acknowledgement and two-space selection, with no API, +Keychain or server-setting writes. Add default phone/iPad CI jobs and a six-size manual matrix; +UI flows rotate to landscape and exercise the largest Dynamic Type/dark state. The official logo +masters are copied byte-for-byte into adaptive native image assets for the official app name. + ## A0 Base (2026-10-16) Scaffold iOS 17+ SwiftUI app from XcodeGen and a local SPM API package. Pin the public design submodule to `fd28f825086a2cac7fa297881f1860b9d332db69`. Generate all light/dark semantic color assets, Swift color accessors, branding config and opaque icon from `branding/brand.json`. Keep the owner-supplied contract unchanged and generate only A0 health/refresh operations and dependent types at build time. diff --git a/docs/LOCAL-DOCKER-VALIDATION.md b/docs/LOCAL-DOCKER-VALIDATION.md index 54e36c0..52b2e89 100644 --- a/docs/LOCAL-DOCKER-VALIDATION.md +++ b/docs/LOCAL-DOCKER-VALIDATION.md @@ -1,6 +1,9 @@ # Local Docker integration evidence The owner selected local Docker as the integration environment on 2026-10-16. +The owner subsequently directed: complete the apps according to the flow and supply the app backend +later. Backend connectivity is therefore deferred; it does not gate UI, local flow, SDK adapter or +device-layout work. Debug preview fixtures must be visibly labeled and isolated from real sessions. The existing development stack was restarted without deleting volumes, changing backend source, or replacing its database. Its API is published on port 8000; the web flow is on port 5000. diff --git a/scripts/device-matrix.py b/scripts/device-matrix.py new file mode 100644 index 0000000..331f4cb --- /dev/null +++ b/scripts/device-matrix.py @@ -0,0 +1,62 @@ +"""Select/create a real available simulator type, failing if the requested class is missing.""" +import json +import os +import pathlib +import subprocess +import sys + +PREFERENCES = { + 'compact': ['iPhone SE (3rd generation)', 'iPhone 13 mini', 'iPhone 16e', 'iPhone 17e'], + 'phone': ['iPhone 16', 'iPhone 17', 'iPhone 15'], + 'large': ['iPhone 16 Pro Max', 'iPhone 17 Pro Max', 'iPhone 15 Pro Max'], + 'tablet': ['iPad mini (A17 Pro)', 'iPad mini (6th generation)', 'iPad (A16)'], + 'tablet-landscape': ['iPad Pro 11-inch (M4)', 'iPad Pro 11-inch (M5)', 'iPad Air 11-inch (M3)'], + 'large-tablet': ['iPad Pro 13-inch (M4)', 'iPad Pro 13-inch (M5)', 'iPad Air 13-inch (M3)'], +} + + +def simctl(*args): + return subprocess.check_output(['xcrun', 'simctl', *args], text=True).strip() + + +def select(profile): + if profile not in PREFERENCES: + raise SystemExit(f'Unknown device profile: {profile}') + runtimes = json.loads(simctl('list', 'runtimes', '-j'))['runtimes'] + runtimes = [r for r in runtimes if r.get('isAvailable') and r['identifier'].startswith('com.apple.CoreSimulator.SimRuntime.iOS-')] + runtimes.sort(key=lambda r: tuple(int(part) for part in r['version'].split('.')), reverse=True) + if not runtimes: + raise SystemExit('No available iOS simulator runtime') + types = json.loads(simctl('list', 'devicetypes', '-j'))['devicetypes'] + by_name = {t['name']: t for t in types} + # Creation validates runtime/hardware compatibility; try the requested alternatives, never another size class. + failures = [] + for name in PREFERENCES[profile]: + if name not in by_name: + continue + for runtime in runtimes: + try: + identifier = simctl('create', f'SpaceWorks-{profile}', by_name[name]['identifier'], runtime['identifier']) + except subprocess.CalledProcessError: + failures.append(f'{name} / {runtime["version"]}') + continue + simctl('boot', identifier) + simctl('bootstatus', identifier, '-b') + # UI tests also cover both appearances. This config exercises enlarged OS text on large devices. + appearance = 'dark' if profile in ('large', 'tablet-landscape') else 'light' + simctl('ui', identifier, 'appearance', appearance) + if profile in ('large', 'tablet-landscape', 'large-tablet'): + simctl('ui', identifier, 'content_size', 'accessibility-extra-extra-extra-large') + evidence = {'profile': profile, 'name': name, 'udid': identifier, 'runtime': runtime['version'], + 'appearance': appearance, 'landscape_required': profile == 'tablet-landscape', + 'large_text': profile in ('large', 'tablet-landscape', 'large-tablet')} + pathlib.Path('validation-device.json').write_text(json.dumps(evidence, indent=2) + '\n', encoding='utf-8') + with open(os.environ['GITHUB_ENV'], 'a', encoding='utf-8') as output: + output.write(f'SIMULATOR_ID={identifier}\nDEVICE_PROFILE={profile}\n') + print(json.dumps(evidence)) + return + raise SystemExit(f'No compatible requested simulator hardware for {profile}; attempted {failures}') + + +if __name__ == '__main__': + select(sys.argv[1]) diff --git a/scripts/generate.py b/scripts/generate.py index 223f123..109fff7 100644 --- a/scripts/generate.py +++ b/scripts/generate.py @@ -9,6 +9,7 @@ import pathlib import plistlib import re +import shutil import subprocess import xml.etree.ElementTree as ET @@ -79,6 +80,13 @@ def generate(raster=True): colors.append(entry) write(assets / f"{role}.colorset/Contents.json", json.dumps({"colors": colors, "info": {"author": "xcode", "version": 1}}, indent=2)) write(ROOT / "SpaceWorks/Generated/Theme.swift", "// Generated from semantic tokens, both appearances.\nimport SwiftUI\nextension Color {\n" + "\n".join(declarations) + "\n}\n") + wordmark = assets / "Wordmark.imageset" + wordmark.mkdir(parents=True, exist_ok=True) + for name, master in [("light.png", "lockup-horizontal-one-colour-ink.png"), ("dark.png", "lockup-horizontal-reversed.png")]: + shutil.copyfile(ROOT / "design/logo" / master, wordmark / name) + write(wordmark / "Contents.json", json.dumps({"images": [{"idiom": "universal", "filename": "light.png"}, + {"idiom": "universal", "filename": "dark.png", "appearances": [{"appearance": "luminosity", "value": "dark"}]}], + "info": {"author": "xcode", "version": 1}})) icon = assets / "AppIcon.appiconset" icon.mkdir(parents=True, exist_ok=True) if raster: diff --git a/scripts/tests/test_device_matrix.py b/scripts/tests/test_device_matrix.py new file mode 100644 index 0000000..f550667 --- /dev/null +++ b/scripts/tests/test_device_matrix.py @@ -0,0 +1,58 @@ +import contextlib +import importlib.util +import io +import json +import os +import pathlib +import tempfile +import unittest +from unittest.mock import patch + +MODULE_PATH = pathlib.Path(__file__).parents[1] / 'device-matrix.py' +spec = importlib.util.spec_from_file_location('device_matrix', MODULE_PATH) +matrix = importlib.util.module_from_spec(spec) +spec.loader.exec_module(matrix) + + +class DeviceMatrixTests(unittest.TestCase): + def test_unknown_profile_fails_before_invoking_simulator(self): + with patch.object(matrix, 'simctl') as simulator: + with self.assertRaises(SystemExit): + matrix.select('unknown') + simulator.assert_not_called() + + def test_tablet_never_silently_falls_back_to_phone(self): + def simulator(*args): + if args[1] == 'runtimes': + return json.dumps({'runtimes': [{'identifier': 'com.apple.CoreSimulator.SimRuntime.iOS-18-5', 'version': '18.5', 'isAvailable': True}]}) + return json.dumps({'devicetypes': [{'name': 'iPhone 16', 'identifier': 'phone-type'}]}) + with patch.object(matrix, 'simctl', side_effect=simulator): + with self.assertRaisesRegex(SystemExit, 'No compatible requested simulator hardware'): + matrix.select('tablet') + + def test_landscape_tablet_records_accessibility_and_requested_hardware(self): + calls = [] + def simulator(*args): + calls.append(args) + if args[:2] == ('list', 'runtimes'): + return json.dumps({'runtimes': [{'identifier': 'com.apple.CoreSimulator.SimRuntime.iOS-18-5', 'version': '18.5', 'isAvailable': True}]}) + if args[:2] == ('list', 'devicetypes'): + return json.dumps({'devicetypes': [{'name': 'iPad Pro 11-inch (M4)', 'identifier': 'tablet-type'}]}) + return 'test-simulator' if args[0] == 'create' else '' + previous = pathlib.Path.cwd() + with tempfile.TemporaryDirectory() as folder: + try: + os.chdir(folder) + with patch.dict(os.environ, {'GITHUB_ENV': str(pathlib.Path(folder) / 'env')}), patch.object(matrix, 'simctl', side_effect=simulator), contextlib.redirect_stdout(io.StringIO()): + matrix.select('tablet-landscape') + evidence = json.loads(pathlib.Path('validation-device.json').read_text()) + self.assertEqual(evidence['name'], 'iPad Pro 11-inch (M4)') + self.assertTrue(evidence['landscape_required']) + self.assertIn(('ui', 'test-simulator', 'content_size', 'accessibility-extra-extra-extra-large'), calls) + self.assertIn('SIMULATOR_ID=test-simulator', pathlib.Path('env').read_text()) + finally: + os.chdir(previous) + + +if __name__ == '__main__': + unittest.main() From 047197fb064c86c73f0d8ea5ecc7d4d7a4b2acd2 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:35:27 +0530 Subject: [PATCH 04/98] fix(a1): match generated login variants and error state Co-Authored-By: Codex --- SpaceWorks/AuthState.swift | 6 +++--- SpaceWorks/Platform/NativeScanner.swift | 11 +++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/SpaceWorks/AuthState.swift b/SpaceWorks/AuthState.swift index ae6f90b..6ed19f1 100644 --- a/SpaceWorks/AuthState.swift +++ b/SpaceWorks/AuthState.swift @@ -62,7 +62,7 @@ final class AuthState { guard await service.refresh.current() != nil else { stage = .signIn; return } try await loadIdentity() } catch { - error = APIError.display(error) + self.error = APIError.display(error) if isUnauthorized(error) { try? await service.refresh.install(nil) } stage = .signIn } @@ -83,9 +83,9 @@ final class AuthState { username: username.trimmingCharacters(in: .whitespacesAndNewlines), password: password, challenge: challenge.challenge, attestationJSON: proof) switch result { - case .case1(let tokens): + case .DeviceTokenResponse(let tokens): try await accept(TokenPair(access: tokens.access, refresh: tokens.refresh)) - case .case2(let challenge): + case .MFAChallengeResponse(let challenge): mfa = challenge mfaExpires = Date().addingTimeInterval(TimeInterval(challenge.expires_in)) if challenge.enrollment_required { enrollment = try await service.enroll(challenge: challenge.challenge) } diff --git a/SpaceWorks/Platform/NativeScanner.swift b/SpaceWorks/Platform/NativeScanner.swift index bdcfc41..f3823af 100644 --- a/SpaceWorks/Platform/NativeScanner.swift +++ b/SpaceWorks/Platform/NativeScanner.swift @@ -9,6 +9,7 @@ struct NativeQRScanner: View { @Environment(\.dismiss) private var dismiss @State private var manual = "" @State private var cameraReady = false + @State private var submitted = false @State private var message: String? var body: some View { @@ -33,7 +34,9 @@ struct NativeQRScanner: View { Text("Enter code manually").font(.headline) TextField("Code", text: $manual) .textInputAutocapitalization(.never).autocorrectionDisabled() - .textFieldStyle(.roundedBorder).accessibilityIdentifier("scanner-manual-code") + .padding(16).background(Color.swPanel, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)) + .accessibilityIdentifier("scanner-manual-code") .submitLabel(.go).onSubmit { complete(manual) } Button { complete(manual) } label: { Text("Review code").frame(minHeight: 48) @@ -65,12 +68,14 @@ struct NativeQRScanner: View { } private func complete(_ value: String) { + guard !submitted else { return } let code = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !code.isEmpty, code.utf8.count <= 2048, code.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) else { message = "Enter a valid code and try again." return } + submitted = true onScan(code) dismiss() } @@ -115,7 +120,9 @@ private struct ScannerCamera: UIViewControllerRepresentable { func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) { guard !delivered else { return } for item in addedItems { - if case .barcode(let barcode) = item, let value = barcode.payloadStringValue, !value.isEmpty { + if case .barcode(let barcode) = item, let value = barcode.payloadStringValue, + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, value.utf8.count <= 2048, + value.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) { delivered = true dataScanner.stopScanning() onScan(value) From 760afe79801ad002a86f130803f1174d3e984e81 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:43:41 +0530 Subject: [PATCH 05/98] feat(a1): complete native identity gates and preview sign-in journeys Co-Authored-By: Codex --- .../Sources/SpaceWorksAPI/Networking.swift | 3 + .../openapi-generator-config.yaml | 1 + SpaceWorks/AuthState.swift | 25 ++++- SpaceWorks/AuthView.swift | 27 ++++++ SpaceWorks/DemoMode.swift | 81 +++++++++++++++- SpaceWorks/Platform/NativeIdentity.swift | 93 +++++++++++++++++++ SpaceWorks/Platform/NativeNotifications.swift | 20 +++- SpaceWorksTests/AuthTests.swift | 62 +++++++++++++ SpaceWorksTests/NativeIdentityTests.swift | 19 ++++ .../NativeNotificationsTests.swift | 13 +++ SpaceWorksUITests/MainFlowTests.swift | 1 + docs/CHANGELOG.md | 4 + docs/NATIVE-SDK-SETUP.md | 25 +++++ project.yml | 5 + scripts/generate.py | 10 +- 15 files changed, 377 insertions(+), 12 deletions(-) create mode 100644 SpaceWorks/Platform/NativeIdentity.swift create mode 100644 SpaceWorksTests/NativeIdentityTests.swift create mode 100644 docs/NATIVE-SDK-SETUP.md diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift index 1a0d90e..5a1e4a9 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift @@ -297,4 +297,7 @@ public final class SpaceWorksService: Sendable { public func signOut() async throws { _ = try await client.api_v1_auth_device_logout_create().ok.body.json } + public func changePassword(current: String, new: String) async throws { + _ = try await client.api_v1_auth_change_password_create(.init(body: .json(.init(current_password: current, new_password: new)))).ok.body.json + } } diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml index ebe0fde..9065253 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml @@ -14,3 +14,4 @@ filter: - api_v1_auth_mfa_enroll_create - api_v1_auth_mfa_confirm_create - api_v1_config_retrieve + - api_v1_auth_change_password_create diff --git a/SpaceWorks/AuthState.swift b/SpaceWorks/AuthState.swift index 6ed19f1..f5aea1a 100644 --- a/SpaceWorks/AuthState.swift +++ b/SpaceWorks/AuthState.swift @@ -4,7 +4,7 @@ import SpaceWorksAPI @MainActor @Observable final class AuthState { - enum Stage: Equatable { case restoring, signIn, mfa, recoveryCodes, signedIn } + enum Stage: Equatable { case restoring, signIn, mfa, recoveryCodes, passwordChange, signedIn } private(set) var stage: Stage = .restoring var username = "" var password = "" @@ -114,7 +114,7 @@ final class AuthState { if !recoveryCodes.isEmpty { stage = .recoveryCodes } } catch { fail(error) } } - func confirmRecoveryCodes() { recoveryCodes = []; stage = .signedIn } + func confirmRecoveryCodes() { recoveryCodes = []; stage = user?.must_change_password == true ? .passwordChange : .signedIn } func backToSignIn() { guard !busy else { return } mfa = nil; mfaExpires = nil; enrollment = nil; code = ""; error = nil; stage = .signIn @@ -132,7 +132,8 @@ final class AuthState { defer { busy = false } do { try await loadIdentity() } catch { fail(error) - if isUnauthorized(error) { try? await service.refresh.install(nil); await clear() } + let hasNoCredentials = await service.refresh.current() == nil + if isUnauthorized(error) || hasNoCredentials { try? await service.refresh.install(nil); await clear() } } } func signOut() async { @@ -147,6 +148,21 @@ final class AuthState { if revocationFailed { error = "Signed out on this device. Server revocation could not be confirmed." } } catch { await clear(); fail(error) } } + func changePassword(current: String, new: String, confirmation: String) async { + guard !busy, (retryAfter ?? .distantPast) <= Date() else { return } + guard !current.isEmpty, !new.isEmpty, new == confirmation else { + error = "Enter your current password and matching new passwords."; return + } + busy = true; error = nil + defer { busy = false } + do { + try await service.changePassword(current: current, new: new) + try? await service.signOut() + try await service.refresh.install(nil) + await clear() + error = "Password updated. Sign in again." + } catch { fail(error) } + } private func accept(_ pair: TokenPair) async throws { guard !pair.access.isEmpty, !pair.refresh.isEmpty else { throw APIError(status: 0, code: "native_session_missing", detail: "This server did not provide a device session. Contact your makerspace.") @@ -161,6 +177,7 @@ final class AuthState { let identity = try await service.me() guard !identity.is_superuser else { try? await service.refresh.install(nil) + await clear() throw APIError(status: 403, code: "device_superadmin_not_supported", detail: "Use the web console") } user = identity @@ -169,7 +186,7 @@ final class AuthState { let selection = saved.flatMap { ids.contains($0) ? $0 : nil } ?? (memberships.count == 1 ? memberships.first?.id : nil) try await service.makerspace.select(selection, activeMemberships: ids) selectedID = selection - stage = .signedIn + stage = identity.must_change_password ? .passwordChange : .signedIn } private func clear() async { try? await service.makerspace.select(nil, activeMemberships: []) diff --git a/SpaceWorks/AuthView.swift b/SpaceWorks/AuthView.swift index 000e7b6..afc59c2 100644 --- a/SpaceWorks/AuthView.swift +++ b/SpaceWorks/AuthView.swift @@ -16,6 +16,7 @@ struct AuthRoot: View { case .restoring: ProgressView("Opening your makerspace…").frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.swBg) case .signIn: SignInView(auth: auth, state: state) case .mfa, .recoveryCodes: MFAView(auth: auth) + case .passwordChange: PasswordChangeView(auth: auth) case .signedIn: if auth.selectedID == nil { MakerspacePicker(auth: auth) } else { AppShell(state: state, auth: auth) } @@ -26,6 +27,32 @@ struct AuthRoot: View { } } +struct PasswordChangeView: View { + let auth: AuthState + @State private var current = "" + @State private var new = "" + @State private var confirmation = "" + var body: some View { + ScrollView { + SWCard { + Text("Choose a new password").font(.largeTitle.bold()) + Text("Update your temporary password before opening your makerspace.").foregroundStyle(Color.swMuted) + Text("Current password").font(.headline) + SecureField("Current password", text: $current).textContentType(.password).padding(14).background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) + Text("New password").font(.headline) + SecureField("New password", text: $new).textContentType(.newPassword).padding(14).background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) + Text("Confirm new password").font(.headline) + SecureField("Confirm new password", text: $confirmation).textContentType(.newPassword).padding(14).background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) + if let error = auth.error { SWError(message: error) } + Button(auth.busy ? "Updating…" : "Update password") { + Task { await auth.changePassword(current: current, new: new, confirmation: confirmation); current = ""; new = ""; confirmation = "" } + }.buttonStyle(SWPrimary()).disabled(auth.busy) + Button("Sign out") { Task { await auth.signOut() } }.frame(minHeight: 44).disabled(auth.busy) + }.padding(24).frame(maxWidth: 560).frame(maxWidth: .infinity) + }.background(Color.swBg) + } +} + struct SWCard: View { @ViewBuilder var content: Content var body: some View { diff --git a/SpaceWorks/DemoMode.swift b/SpaceWorks/DemoMode.swift index ffeee6a..c6f364a 100644 --- a/SpaceWorks/DemoMode.swift +++ b/SpaceWorks/DemoMode.swift @@ -5,8 +5,11 @@ import Observation @MainActor @Observable final class DemoSession { enum Role: String, CaseIterable { case member = "Member", staff = "Staff" } - enum Scenario: String, CaseIterable { case password = "Password", mfa = "MFA", enrollment = "Enrollment" } - enum Stage: Equatable { case welcome, signIn, mfa, recovery, makerspace, shell } + enum Scenario: String, CaseIterable { + case password = "Password", mfa = "MFA", enrollment = "Enrollment", expired = "Expired challenge" + case offline = "Offline", throttled = "Too many requests", superadmin = "Superadmin", updateRequired = "Update required" + } + enum Stage: Equatable { case welcome, signIn, mfa, recovery, makerspace, shell, webConsole, updateRequired } var role = Role.member var scenario = Scenario.password var stage = Stage.welcome @@ -14,18 +17,35 @@ final class DemoSession { var password = "" var code = "" var error: String? + var retryAfter: Date? var selectedSpace = 0 let spaces = ["Fab Lab Kochi", "Maker Space Bengaluru"] var name: String { role == .member ? "Meera Nair" : "Arjun Rao" } func signIn() { guard !username.isEmpty, !password.isEmpty else { error = "Enter your email or username and password."; return } - error = nil; password = ""; stage = scenario == .password ? .makerspace : .mfa + guard (retryAfter ?? .distantPast) <= Date() else { return } + error = nil; password = "" + switch scenario { + case .offline: error = "Couldn't connect. Try again." + case .throttled: error = "Too many requests. Try again later."; retryAfter = Date().addingTimeInterval(10) + case .expired: error = "Your sign-in expired. Try again." + case .superadmin: stage = .webConsole + case .updateRequired: stage = .updateRequired + case .password: stage = .makerspace + case .mfa, .enrollment: stage = .mfa + } } func verify() { guard code == "123456" || code == "sample-recovery" else { error = "That code didn’t work. Try again."; return } code = ""; error = nil; stage = scenario == .enrollment ? .recovery : .makerspace } - func reset() { username = ""; password = ""; code = ""; error = nil; selectedSpace = 0; stage = .welcome } + func acceptDemoProvider() { error = nil; stage = scenario == .enrollment || scenario == .mfa ? .mfa : .makerspace } + func reset() { username = ""; password = ""; code = ""; error = nil; retryAfter = nil; selectedSpace = 0; stage = .welcome } +} + +enum DemoProvider: String, CaseIterable, Identifiable { + case google = "Google", apple = "Apple", organization = "your organization", phone = "phone" + var id: Self { self } } struct DemoRoot: View { @@ -47,6 +67,7 @@ struct DemoRoot: View { struct DemoAuthView: View { @Bindable var session: DemoSession @FocusState private var passwordFocused: Bool + @State private var provider: DemoProvider? var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { @@ -63,6 +84,12 @@ struct DemoAuthView: View { case .signIn: Text("Welcome back.").font(.largeTitle.bold()) Text("Your makerspace, in your pocket.").foregroundStyle(Color.swMuted) + ForEach(DemoProvider.allCases.filter { $0 != .phone || session.role == .member }) { choice in + Button("Continue with \(choice.rawValue)") { provider = choice } + .frame(maxWidth: .infinity, minHeight: 48) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)) + } + Text("or with your email").font(.caption).foregroundStyle(Color.swMuted) Text("Email or username").font(.headline) TextField("Email or username", text: $session.username).textInputAutocapitalization(.never) .autocorrectionDisabled().demoField().accessibilityIdentifier("demo-username") @@ -72,7 +99,10 @@ struct DemoAuthView: View { .demoField().accessibilityIdentifier("demo-password") Text("Use any email and password in this demo.").font(.caption).foregroundStyle(Color.swMuted) if let error = session.error { SWError(message: error) } - Button("Sign in") { session.signIn() }.buttonStyle(SWPrimary()).accessibilityIdentifier("demo-sign-in") + TimelineView(.periodic(from: .now, by: 1)) { context in + Button("Sign in") { session.signIn() }.buttonStyle(SWPrimary()).accessibilityIdentifier("demo-sign-in") + .disabled((session.retryAfter ?? .distantPast) > context.date) + } Button("Back") { session.reset() }.frame(minHeight: 44) case .mfa: Text(session.scenario == .enrollment ? "Set up two-step sign-in" : "Verify your sign-in").font(.largeTitle.bold()) @@ -100,11 +130,52 @@ struct DemoAuthView: View { Label(session.spaces[index], systemImage: "building.2").frame(maxWidth: .infinity, minHeight: 52, alignment: .leading) }.buttonStyle(.plain).accessibilityIdentifier("demo-space-\(index)") } + case .webConsole: + Text("Use the web console").font(.largeTitle.bold()) + Text("Superadmin accounts cannot sign in on a phone or tablet.").foregroundStyle(Color.swMuted) + Button("Back to demo") { session.reset() }.buttonStyle(SWPrimary()) + case .updateRequired: + Text("Update required").font(.largeTitle.bold()) + Text("Update the app to keep using this server. This is a preview of the server's minimum-version gate.").foregroundStyle(Color.swMuted) + Button("Back to demo") { session.reset() }.buttonStyle(SWPrimary()) case .shell: EmptyView() } } }.padding(24).frame(maxWidth: 600).frame(maxWidth: .infinity) }.background(Color.swBg).scrollDismissesKeyboard(.interactively) + .sheet(item: $provider) { DemoProviderView(session: session, provider: $0) } + } +} +struct DemoProviderView: View { + let session: DemoSession + let provider: DemoProvider + @Environment(\.dismiss) private var dismiss + @State private var phone = "" + @State private var code = "" + @State private var error: String? + var body: some View { + NavigationStack { + ScrollView { + SWCard { + Text("Continue with \(provider.rawValue)").font(.largeTitle.bold()) + Text("Demo only. No provider account is connected and no message is sent.").foregroundStyle(Color.swMuted) + if provider == .phone { + Text("Phone number").font(.headline) + TextField("Phone number", text: $phone).textContentType(.telephoneNumber).keyboardType(.phonePad).demoField() + Text("Verification code").font(.headline) + TextField("Verification code", text: $code).textContentType(.oneTimeCode).demoField() + Text("Use 123456 in this demo.").font(.caption).foregroundStyle(Color.swMuted) + } else { + Label(session.name, systemImage: "person.crop.circle").font(.title2) + } + if let error { SWError(message: error) } + Button("Continue demo account") { + if provider == .phone && (phone.isEmpty || code != "123456") { error = "Enter a phone number and the demo code 123456."; return } + session.acceptDemoProvider(); dismiss() + }.buttonStyle(SWPrimary()) + }.padding(24).frame(maxWidth: 560).frame(maxWidth: .infinity) + }.background(Color.swBg).toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() }.frame(minHeight: 44) } } + } } } diff --git a/SpaceWorks/Platform/NativeIdentity.swift b/SpaceWorks/Platform/NativeIdentity.swift new file mode 100644 index 0000000..e6be2f9 --- /dev/null +++ b/SpaceWorks/Platform/NativeIdentity.swift @@ -0,0 +1,93 @@ +import AuthenticationServices +import GoogleSignIn +import SpaceWorksAPI +import UIKit + +/// Provider SDK seams. Live buttons remain gated until the server publishes native client configuration. +@MainActor enum NativeIdentity { + static func google(clientID: String, serverClientID: String?, nonce: String, presenting: UIViewController) async throws -> String { + guard clientID.hasSuffix(".apps.googleusercontent.com"), !nonce.isEmpty else { throw unavailable } + let prefix = String(clientID.dropLast(".apps.googleusercontent.com".count)) + let reversed = "com.googleusercontent.apps.\(prefix)" + let registrations = Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") as? [[String: Any]] ?? [] + guard registrations.contains(where: { ($0["CFBundleURLSchemes"] as? [String])?.contains(reversed) == true }) else { throw unavailable } + let provider = GIDSignIn.sharedInstance + provider.configuration = GIDConfiguration(clientID: clientID, serverClientID: serverClientID) + // The app owns the device grant. Avoid retaining an independent Google account session. + defer { provider.signOut() } + let result = try await provider.signIn(withPresenting: presenting, hint: nil, additionalScopes: nil, nonce: nonce) + try Task.checkCancellation() + guard let token = result.user.idToken?.tokenString, !token.isEmpty else { throw unavailable } + return token + } + static func handleGoogleCallback(_ url: URL) -> Bool { GIDSignIn.sharedInstance.handle(url) } + nonisolated static var unavailable: APIError { + APIError(status: 0, code: "native_provider_unconfigured", detail: "Native sign-in is not configured for this build. Contact your makerspace.") + } +} + +@MainActor final class AppleIdentity: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding { + private var continuation: CheckedContinuation? + private var anchor: ASPresentationAnchor? + private var controller: ASAuthorizationController? + func token(nonce: String, anchor: ASPresentationAnchor) async throws -> String { + guard !nonce.isEmpty, continuation == nil else { throw NativeIdentity.unavailable } + self.anchor = anchor + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + let request = ASAuthorizationAppleIDProvider().createRequest() + request.requestedScopes = [.fullName, .email] + // Match the server-issued nonce contract also consumed by the existing frontend. + request.nonce = nonce + let controller = ASAuthorizationController(authorizationRequests: [request]) + self.controller = controller + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests() + } + } + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { anchor! } + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential, + let data = credential.identityToken, let token = String(data: data, encoding: .utf8), !token.isEmpty else { + finish(.failure(NativeIdentity.unavailable)); return + } + finish(.success(token)) + } + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { finish(.failure(error)) } + private func finish(_ result: Result) { + let pending = continuation; continuation = nil; controller = nil; anchor = nil + pending?.resume(with: result) + } +} + +/// ASWebAuthenticationSession is the sole browser-auth surface. The backend native OIDC callback is deferred. +@MainActor final class NativeOIDCBrowser: NSObject, ASWebAuthenticationPresentationContextProviding { + private var session: ASWebAuthenticationSession? + private var anchor: ASPresentationAnchor? + func authorize(url: URL, expectedState: String, anchor: ASPresentationAnchor) async throws -> String { + guard url.scheme == "https", url.user == nil, url.password == nil, !expectedState.isEmpty, session == nil else { throw NativeIdentity.unavailable } + self.anchor = anchor + defer { session = nil; self.anchor = nil } + let callback: URL = try await withCheckedThrowingContinuation { continuation in + let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "spaceworks") { callback, error in + if let error { continuation.resume(throwing: error) } + else if let callback { continuation.resume(returning: callback) } + else { continuation.resume(throwing: NativeIdentity.unavailable) } + } + session.prefersEphemeralWebBrowserSession = true + session.presentationContextProvider = self + self.session = session + if !session.start() { continuation.resume(throwing: NativeIdentity.unavailable) } + } + try Task.checkCancellation() + guard callback.scheme == "spaceworks", callback.host == "auth", callback.path == "/callback", + let parts = URLComponents(url: callback, resolvingAgainstBaseURL: false), + parts.queryItems?.filter({ $0.name == "state" }).count == 1, + parts.queryItems?.filter({ $0.name == "code" }).count == 1, parts.fragment == nil, + parts.queryItems?.first(where: { $0.name == "state" })?.value == expectedState, + let code = parts.queryItems?.first(where: { $0.name == "code" })?.value, !code.isEmpty else { throw NativeIdentity.unavailable } + return code + } + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { anchor! } +} diff --git a/SpaceWorks/Platform/NativeNotifications.swift b/SpaceWorks/Platform/NativeNotifications.swift index 5a47e57..cf857a5 100644 --- a/SpaceWorks/Platform/NativeNotifications.swift +++ b/SpaceWorks/Platform/NativeNotifications.swift @@ -46,8 +46,11 @@ final class NativeNotifications { private(set) var authorization: UNAuthorizationStatus = .notDetermined private(set) var token: String? private(set) var error: String? + private(set) var pendingHint: NotificationHint? var onTokenChanged: ((String) -> Void)? - var onTap: ((NotificationHint) -> Void)? + var onTap: ((NotificationHint) -> Void)? { + didSet { deliverPendingHint() } + } var mayPresent: ((NotificationHint) -> Bool)? func requestPermission() async { @@ -76,12 +79,25 @@ final class NativeNotifications { func registrationFailed() { error = "Push registration is unavailable. Your inbox is still available." } + /// Preserve a cold-launch tap until authentication and membership routing have been installed. + func receive(_ hint: NotificationHint) { + pendingHint = hint + deliverPendingHint() + } + + private func deliverPendingHint() { + guard let hint = pendingHint, let handler = onTap else { return } + pendingHint = nil + handler(hint) + } + /// Call after removing server registration IDs. No prior user's notification routing survives logout. func reset() { UIApplication.shared.unregisterForRemoteNotifications() UNUserNotificationCenter.current().removeAllDeliveredNotifications() token = nil error = nil + pendingHint = nil onTokenChanged = nil onTap = nil mayPresent = nil @@ -106,7 +122,7 @@ final class NativeNotificationDelegate: NSObject, UIApplicationDelegate, UNUserN withCompletionHandler completionHandler: @escaping () -> Void) { let hint = NotificationHint(payload: response.notification.request.content.userInfo) Task { @MainActor in - if let hint { NativeNotifications.shared.onTap?(hint) } + if let hint { NativeNotifications.shared.receive(hint) } completionHandler() } } diff --git a/SpaceWorksTests/AuthTests.swift b/SpaceWorksTests/AuthTests.swift index 87ba51e..994de54 100644 --- a/SpaceWorksTests/AuthTests.swift +++ b/SpaceWorksTests/AuthTests.swift @@ -137,4 +137,66 @@ final class MemoryTokens: TokenStorage, @unchecked Sendable { demo.code = "123456"; demo.verify(); XCTAssertEqual(demo.stage, .recovery) demo.reset(); XCTAssertEqual(demo.stage, .welcome); XCTAssertTrue(demo.password.isEmpty) } + func testForegroundSuperadminChangeWipesPreviousIdentity() async throws { + let storage = MemoryTokens(TokenPair(access: "test-access", refresh: "test-refresh")) + let auth = state(storage: storage, handler: response) + await auth.restore(); await auth.select(7) + StubProtocol.lock.lock() + StubProtocol.handler = { _ in + (200, self.json, Data(DebugAuthFixtures.user(memberships: [], superuser: true).utf8)) + } + StubProtocol.lock.unlock() + await auth.foreground() + XCTAssertEqual(auth.stage, .signIn); XCTAssertNil(auth.user); XCTAssertNil(auth.selectedID) + XCTAssertNil(try storage.load()); XCTAssertEqual(auth.error, "Use the web console") + } + func testNativeMFAMustHaveDeviceGrantAndNonVerificationSurface() async { + for completion in ["{\"access\":\"test-access\",\"refresh\":\"test-refresh\",\"surface\":\"member\",\"user\":\(DebugAuthFixtures.user)}", + "{\"access\":\"test-access\",\"refresh\":\"test-refresh\",\"surface\":\"verification_only\",\"device_grant\":\(DebugAuthFixtures.grant),\"user\":\(DebugAuthFixtures.user)}"] { + let auth = state { request in + if request.url!.path.hasSuffix("/login") { return (200, DebugAuthFixtures.mfa) } + if request.url!.path.hasSuffix("/verify") { return (200, completion) } + return try self.response(request) + } + await auth.restore(); auth.username = "maker"; auth.password = "sample"; await auth.signIn() + auth.code = "123456"; await auth.verify() + XCTAssertEqual(auth.stage, .mfa); XCTAssertNil(auth.user) + } + } + func testExpiredMFAReturnsToSignInWithoutUsingChallenge() async { + let auth = state { request in + if request.url!.path.hasSuffix("/login") { return (200, DebugAuthFixtures.mfa.replacingOccurrences(of: "\"expires_in\":300", with: "\"expires_in\":-1")) } + return try self.response(request) + } + await auth.restore(); auth.username = "maker"; auth.password = "sample"; await auth.signIn() + auth.code = "123456"; await auth.verify() + XCTAssertEqual(auth.stage, .signIn); XCTAssertEqual(auth.error, "Your sign-in expired. Try again.") + } + func testTemporaryPasswordGateChangesPasswordThenWipesSession() async throws { + let storage = MemoryTokens(TokenPair(access: "test-access", refresh: "test-refresh")) + let auth = state(storage: storage) { request in + if request.url!.path.hasSuffix("/me") { return (200, DebugAuthFixtures.user.replacingOccurrences(of: "\"must_change_password\":false", with: "\"must_change_password\":true")) } + if request.url!.path.hasSuffix("/change-password") { + let body = try JSONSerialization.jsonObject(with: requestBody(request)) as! [String: String] + XCTAssertEqual(body, ["current_password": "temporary", "new_password": "replacement"]) + return (200, #"{"detail":"Updated."}"#) + } + return try self.response(request) + } + await auth.restore(); XCTAssertEqual(auth.stage, .passwordChange) + await auth.changePassword(current: "temporary", new: "replacement", confirmation: "replacement") + XCTAssertEqual(auth.stage, .signIn); XCTAssertNil(try storage.load()) + XCTAssertEqual(auth.error, "Password updated. Sign in again.") + } + func testDemoBlockedStatesAreExplicitAndResettable() { + let demo = DemoSession() + for scenario in [DemoSession.Scenario.offline, .throttled, .expired] { + demo.reset(); demo.scenario = scenario; demo.username = "maker"; demo.password = "sample"; demo.stage = .signIn + demo.signIn(); XCTAssertEqual(demo.stage, .signIn); XCTAssertNotNil(demo.error) + } + demo.reset(); demo.scenario = .superadmin; demo.username = "maker"; demo.password = "sample"; demo.signIn() + XCTAssertEqual(demo.stage, .webConsole) + demo.reset(); demo.scenario = .updateRequired; demo.username = "maker"; demo.password = "sample"; demo.signIn() + XCTAssertEqual(demo.stage, .updateRequired) + } } diff --git a/SpaceWorksTests/NativeIdentityTests.swift b/SpaceWorksTests/NativeIdentityTests.swift new file mode 100644 index 0000000..1b18a17 --- /dev/null +++ b/SpaceWorksTests/NativeIdentityTests.swift @@ -0,0 +1,19 @@ +import XCTest +import UIKit +@testable import SpaceWorks + +@MainActor final class NativeIdentityTests: XCTestCase { + func testGoogleWithoutRegisteredBuildConfigurationFailsBeforeSDKLaunch() async { + do { + _ = try await NativeIdentity.google(clientID: "", serverClientID: nil, nonce: "sample", presenting: UIViewController()) + XCTFail("Unconfigured provider must fail before presentation") + } catch { XCTAssertEqual(error.localizedDescription, NativeIdentity.unavailable.localizedDescription) } + } + func testOrganizationBrowserRejectsInsecureURLBeforePresentation() async { + let browser = NativeOIDCBrowser() + do { + _ = try await browser.authorize(url: URL(string: "http://identity.example/authorize")!, expectedState: "sample", anchor: UIWindow(frame: .zero)) + XCTFail("Insecure identity provider must fail before presentation") + } catch { XCTAssertEqual(error.localizedDescription, NativeIdentity.unavailable.localizedDescription) } + } +} diff --git a/SpaceWorksTests/NativeNotificationsTests.swift b/SpaceWorksTests/NativeNotificationsTests.swift index aa21363..5ed2c36 100644 --- a/SpaceWorksTests/NativeNotificationsTests.swift +++ b/SpaceWorksTests/NativeNotificationsTests.swift @@ -38,4 +38,17 @@ final class NativeNotificationsTests: XCTestCase { XCTAssertNil(hint?.eventID) XCTAssertNil(hint?.relativeLink) } + + @MainActor func testColdLaunchHintWaitsForAuthenticatedRouterAndDeliversOnce() throws { + let notifications = NativeNotifications() + let hint = try XCTUnwrap(NotificationHint(payload: ["makerspace_id": "7"])) + notifications.receive(hint) + XCTAssertEqual(notifications.pendingHint, hint) + var received: [NotificationHint] = [] + notifications.onTap = { received.append($0) } + XCTAssertEqual(received, [hint]) + XCTAssertNil(notifications.pendingHint) + notifications.onTap = { received.append($0) } + XCTAssertEqual(received.count, 1) + } } diff --git a/SpaceWorksUITests/MainFlowTests.swift b/SpaceWorksUITests/MainFlowTests.swift index 740558d..7ff24ce 100644 --- a/SpaceWorksUITests/MainFlowTests.swift +++ b/SpaceWorksUITests/MainFlowTests.swift @@ -72,6 +72,7 @@ final class MainFlowTests: XCTestCase { let app = launch(); app.buttons["explore-demo"].tap() XCTAssertTrue(app.staticTexts["Demo mode · sample data"].exists) app.buttons["Continue as member"].tap() + app.swipeUp() app.textFields["demo-username"].tap(); app.textFields["demo-username"].typeText("meera") app.secureTextFields["demo-password"].tap(); app.secureTextFields["demo-password"].typeText("sample\n") app.swipeUp(); app.buttons["demo-sign-in"].tap() diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4393e9c..83bff23 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -31,6 +31,10 @@ enrollment QR/manual secret, recovery-code acknowledgement and two-space selecti Keychain or server-setting writes. Add default phone/iPad CI jobs and a six-size manual matrix; UI flows rotate to landscape and exercise the largest Dynamic Type/dark state. The official logo masters are copied byte-for-byte into adaptive native image assets for the official app name. +Add the mandatory temporary-password gate, native Google SDK/Apple authorization/ephemeral OIDC +adapter seams, and explicit demo provider/phone, expired/offline/throttled/superadmin/update-required +journeys. Live provider buttons stay closed pending native configuration/contracts; no web tokens +are treated as device sessions. Provider setup is recorded in `docs/NATIVE-SDK-SETUP.md`. ## A0 Base (2026-10-16) diff --git a/docs/NATIVE-SDK-SETUP.md b/docs/NATIVE-SDK-SETUP.md new file mode 100644 index 0000000..3b8d928 --- /dev/null +++ b/docs/NATIVE-SDK-SETUP.md @@ -0,0 +1,25 @@ +# Native provider setup + +SDK code is compiled in CI. A compiled adapter is not evidence of a configured provider or a +completed real-device transaction. The owner deferred the app backend; Debug demo flows never invoke +these SDKs or send their sample values to a service. + +| Integration | Native adapter | Owner/server setup | +|---|---|---| +| Device integrity | DeviceCheck App Attest; fresh key per sign-in, SHA-256 challenge | Registered bundle/team, App Attest capability, server app allowlist and production proof contract | +| Google | Official GoogleSignIn 10.0.0, exact SPM release | Native iOS and server client IDs from approved native config; optional `google_reversed_client_id` in brand.json must be the registered reversed iOS client ID | +| Apple | AuthenticationServices authorization controller | Sign in with Apple capability, native token audience/server fix and approved native config | +| Organization OIDC | Ephemeral ASWebAuthenticationSession, validated state and exact `spaceworks://auth/callback` | Native device delivery callback contract; current web-only callback cannot be substituted | +| Push | UserNotifications/APNs delegate | APNs capability, owner provider key/server link files and server registration setup | +| QR | VisionKit scanner and explicit manual-code fallback | Real camera permission/device; server resolves the opaque code | + +Live sign-in buttons are not rendered from web client IDs. The current schema does not include the +native provider/version configuration and social MFA union; update the authoritative snapshot when +the owner supplies them. Google refuses to launch without its registered callback scheme and clears +its SDK session after obtaining a token; the app's Keychain device grant remains the sole app session. +Provider operations return opaque identity tokens to the generated-client integration seam; tokens +are never logged or added to defaults. The original PRD remains unchanged. + +Sources: [Google release](https://github.com/google/GoogleSignIn-iOS/releases/tag/10.0.0), +[Google integration](https://developers.google.com/identity/sign-in/ios/sign-in), +[App Attest](https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity). diff --git a/project.yml b/project.yml index 79e92c8..0af6611 100644 --- a/project.yml +++ b/project.yml @@ -16,6 +16,9 @@ settings: ENABLE_USER_SCRIPT_SANDBOXING: NO TARGETED_DEVICE_FAMILY: '1,2' packages: + GoogleSignIn: + url: https://github.com/google/GoogleSignIn-iOS.git + exactVersion: 10.0.0 SpaceWorksAPI: path: Packages/SpaceWorksAPI targets: @@ -27,6 +30,8 @@ targets: excludes: [Generated/Info-Debug.plist, Generated/Info-Release.plist] dependencies: - package: SpaceWorksAPI + - package: GoogleSignIn + product: GoogleSignIn settings: configs: Debug: diff --git a/scripts/generate.py b/scripts/generate.py index 109fff7..fea4899 100644 --- a/scripts/generate.py +++ b/scripts/generate.py @@ -53,12 +53,20 @@ def generate(raster=True): info = {"CFBundleDisplayName": brand["app_name"], "UILaunchScreen": {}, "UIApplicationSceneManifest": {"UIApplicationSupportsMultipleScenes": False}} info["UISupportedInterfaceOrientations"] = ["UIInterfaceOrientationPortrait", "UIInterfaceOrientationLandscapeLeft", "UIInterfaceOrientationLandscapeRight"] info["UISupportedInterfaceOrientations~ipad"] = info["UISupportedInterfaceOrientations"] + ["UIInterfaceOrientationPortraitUpsideDown"] + schemes = ["spaceworks"] + google_scheme = brand.get("google_reversed_client_id") + if google_scheme is not None: + if not re.fullmatch(r"com\.googleusercontent\.apps\.[A-Za-z0-9.-]+", google_scheme): + raise ValueError("Invalid Google reversed client ID") + schemes.append(google_scheme) + info["CFBundleURLTypes"] = [{"CFBundleURLName": brand["ios_bundle_id"], "CFBundleURLSchemes": schemes}] if mode == "Debug": info["NSAppTransportSecurity"] = {"NSAllowsLocalNetworking": True} target = ROOT / f"SpaceWorks/Generated/Info-{mode}.plist" target.write_bytes(plistlib.dumps(info)) (ROOT / f"SpaceWorks/Generated/Attestation-{mode}.entitlements").write_bytes(plistlib.dumps({ - "com.apple.developer.devicecheck.appattest-environment": "development" if mode == "Debug" else "production"})) + "com.apple.developer.devicecheck.appattest-environment": "development" if mode == "Debug" else "production", + "com.apple.developer.applesignin": ["Default"]})) tokens = json.loads(inside(brand["theme_tokens"]).read_text()) if set(tokens["light"]) != set(tokens["dark"]): raise ValueError("Light and dark semantic roles must match") From 1e9d6d05821a68884c2a5747c98f2f5afd568be7 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:03:54 +0530 Subject: [PATCH 06/98] fix(a1): sign simulator Keychain identity and preserve password focus Co-Authored-By: Codex --- .github/workflows/ios.yml | 16 ++++++++++++++-- .gitignore | 2 ++ .../Sources/SpaceWorksAPI/TokenVault.swift | 4 +++- SpaceWorks/AuthView.swift | 3 ++- SpaceWorks/DemoMode.swift | 3 ++- SpaceWorksUITests/MainFlowTests.swift | 8 ++++---- docs/CHANGELOG.md | 6 ++++++ project.yml | 1 + scripts/generate.py | 6 ++++++ scripts/tests/test_generation.py | 11 +++++++++++ 10 files changed, 51 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index eb9fdcd..2ffcfcf 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -11,7 +11,7 @@ concurrency: jobs: build-and-test: runs-on: macos-15 - timeout-minutes: 45 + timeout-minutes: 60 name: Build and test (${{ matrix.device }}) strategy: fail-fast: false @@ -24,6 +24,12 @@ jobs: submodules: recursive - name: Install project and icon generators run: brew install xcodegen librsvg + - name: Cache Swift package sources + uses: actions/cache@v4 + with: + path: SourcePackages + key: ${{ runner.os }}-${{ runner.arch }}-spm-${{ hashFiles('project.yml', 'Packages/**/Package.swift') }} + restore-keys: ${{ runner.os }}-${{ runner.arch }}-spm- - name: Generate and validate source inputs run: | python3 scripts/generate.py @@ -36,13 +42,18 @@ jobs: - name: Compile and test on simulator run: | set -o pipefail - xcodebuild test -project SpaceWorks.xcodeproj -scheme SpaceWorks -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -derivedDataPath DerivedData -resultBundlePath TestResults.xcresult -skipPackagePluginValidation CODE_SIGNING_ALLOWED=NO | tee build.log + xcodebuild test -project SpaceWorks.xcodeproj -scheme SpaceWorks -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -resultBundlePath TestResults.xcresult -skipPackagePluginValidation CODE_SIGN_STYLE=Manual CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= | tee build.log - name: Export UI screenshots if: always() run: | if [ -d TestResults.xcresult ]; then xcrun xcresulttool export attachments --path TestResults.xcresult --output-path TestAttachments || true fi + - name: Compile unsigned Release for a physical iOS device + if: success() && matrix.device == 'phone' + run: | + set -o pipefail + xcodebuild build -project SpaceWorks.xcodeproj -scheme SpaceWorks -configuration Release -destination 'generic/platform=iOS' -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -skipPackagePluginValidation CODE_SIGNING_ALLOWED=NO | tee release-build.log - name: Archive validation evidence if: always() uses: actions/upload-artifact@v4 @@ -52,6 +63,7 @@ jobs: TestResults.xcresult TestAttachments build.log + release-build.log validation-device.json DerivedData/Build/Intermediates.noindex/BuildToolPluginIntermediates/**/GeneratedSources/*.swift SpaceWorks.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/.gitignore b/.gitignore index 50b653c..4297147 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ __pycache__/ /validation-device.json /TestAttachments/ /build.log +/SourcePackages/ +/release-build.log diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/TokenVault.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/TokenVault.swift index bd9feed..ef92370 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/TokenVault.swift +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/TokenVault.swift @@ -48,6 +48,8 @@ public struct TokenVault: TokenStorage { APIError(status: 0, code: "secure_storage_unavailable", detail: "Secure storage is unavailable. Unlock your device and try again.") } private func check(_ status: OSStatus) throws { - guard status == errSecSuccess else { throw vaultError } + guard status == errSecSuccess else { + throw APIError(status: 0, code: "secure_storage_unavailable_\(status)", detail: vaultError.detail) + } } } diff --git a/SpaceWorks/AuthView.swift b/SpaceWorks/AuthView.swift index afc59c2..b7ddabf 100644 --- a/SpaceWorks/AuthView.swift +++ b/SpaceWorks/AuthView.swift @@ -99,7 +99,8 @@ struct SignInView: View { } else { field("Email or username") { TextField("Email or username", text: $auth.username).textContentType(.username) - .textInputAutocapitalization(.never).autocorrectionDisabled().accessibilityIdentifier("username") + .textInputAutocapitalization(.never).autocorrectionDisabled() + .submitLabel(.next).onSubmit { passwordFocused = true }.accessibilityIdentifier("username") } field("Password") { HStack { diff --git a/SpaceWorks/DemoMode.swift b/SpaceWorks/DemoMode.swift index c6f364a..9cbe065 100644 --- a/SpaceWorks/DemoMode.swift +++ b/SpaceWorks/DemoMode.swift @@ -92,7 +92,8 @@ struct DemoAuthView: View { Text("or with your email").font(.caption).foregroundStyle(Color.swMuted) Text("Email or username").font(.headline) TextField("Email or username", text: $session.username).textInputAutocapitalization(.never) - .autocorrectionDisabled().demoField().accessibilityIdentifier("demo-username") + .autocorrectionDisabled().submitLabel(.next).onSubmit { passwordFocused = true } + .demoField().accessibilityIdentifier("demo-username") Text("Password").font(.headline) SecureField("Password", text: $session.password).focused($passwordFocused) .submitLabel(.done).onSubmit { passwordFocused = false } diff --git a/SpaceWorksUITests/MainFlowTests.swift b/SpaceWorksUITests/MainFlowTests.swift index 7ff24ce..4d7be61 100644 --- a/SpaceWorksUITests/MainFlowTests.swift +++ b/SpaceWorksUITests/MainFlowTests.swift @@ -16,8 +16,8 @@ final class MainFlowTests: XCTestCase { XCTAssertTrue(app.textFields["username"].waitForExistence(timeout: 10)) } private func credentials(_ app: XCUIApplication) { - app.textFields["username"].tap(); app.textFields["username"].typeText("maker") - app.secureTextFields["password"].tap(); app.secureTextFields["password"].typeText("fixture-password\n") + app.textFields["username"].tap(); app.textFields["username"].typeText("maker\n") + app.secureTextFields["password"].typeText("fixture-password\n") app.swipeUp() app.buttons["sign-in"].tap() } @@ -73,8 +73,8 @@ final class MainFlowTests: XCTestCase { XCTAssertTrue(app.staticTexts["Demo mode · sample data"].exists) app.buttons["Continue as member"].tap() app.swipeUp() - app.textFields["demo-username"].tap(); app.textFields["demo-username"].typeText("meera") - app.secureTextFields["demo-password"].tap(); app.secureTextFields["demo-password"].typeText("sample\n") + app.textFields["demo-username"].tap(); app.textFields["demo-username"].typeText("meera\n") + app.secureTextFields["demo-password"].typeText("sample\n") app.swipeUp(); app.buttons["demo-sign-in"].tap() app.buttons["demo-space-0"].tap() XCTAssertTrue(app.staticTexts["Welcome back, Meera Nair."].waitForExistence(timeout: 5)) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 83bff23..c947b72 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -36,6 +36,12 @@ adapter seams, and explicit demo provider/phone, expired/offline/throttled/super journeys. Live provider buttons stay closed pending native configuration/contracts; no web tokens are treated as device sessions. Provider setup is recorded in `docs/NATIVE-SDK-SETUP.md`. +CI integration: the unsigned simulator app could not access Keychain. Give simulator builds an +ad-hoc signed, local-only application/access-group identity; physical-device entitlements continue +to come from owner provisioning. Keep the actual Keychain round-trip test and add a generation test +that proves the simulator identity cannot enter a device archive. Keyboard Next/Done focus is +explicit in both live and demo forms so tablet keyboards never cover the password action. + ## A0 Base (2026-10-16) Scaffold iOS 17+ SwiftUI app from XcodeGen and a local SPM API package. Pin the public design submodule to `fd28f825086a2cac7fa297881f1860b9d332db69`. Generate all light/dark semantic color assets, Swift color accessors, branding config and opaque icon from `branding/brand.json`. Keep the owner-supplied contract unchanged and generate only A0 health/refresh operations and dependent types at build time. diff --git a/project.yml b/project.yml index 0af6611..cab55b4 100644 --- a/project.yml +++ b/project.yml @@ -47,6 +47,7 @@ targets: INFOPLIST_KEY_UILaunchScreen_Generation: YES INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + 'CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]': SpaceWorks/Generated/Simulator.entitlements preBuildScripts: - name: Generate branding and semantic theme script: python3 "$SRCROOT/scripts/generate.py" diff --git a/scripts/generate.py b/scripts/generate.py index fea4899..0bc5d01 100644 --- a/scripts/generate.py +++ b/scripts/generate.py @@ -67,6 +67,12 @@ def generate(raster=True): (ROOT / f"SpaceWorks/Generated/Attestation-{mode}.entitlements").write_bytes(plistlib.dumps({ "com.apple.developer.devicecheck.appattest-environment": "development" if mode == "Debug" else "production", "com.apple.developer.applesignin": ["Default"]})) + # Simulator Keychain requires an effective signed app identity, even without an owner team. + # This local-only identity is never used in a physical-device build or release archive. + simulator_identity = "LOCALCI." + brand["ios_bundle_id"] + (ROOT / "SpaceWorks/Generated/Simulator.entitlements").write_bytes(plistlib.dumps({ + "application-identifier": simulator_identity, + "keychain-access-groups": [simulator_identity], "get-task-allow": True})) tokens = json.loads(inside(brand["theme_tokens"]).read_text()) if set(tokens["light"]) != set(tokens["dark"]): raise ValueError("Light and dark semantic roles must match") diff --git a/scripts/tests/test_generation.py b/scripts/tests/test_generation.py index 923ff1f..8e98080 100644 --- a/scripts/tests/test_generation.py +++ b/scripts/tests/test_generation.py @@ -79,6 +79,17 @@ def test_release_has_no_transport_security_exceptions(self): self.assertEqual(debug["NSAppTransportSecurity"], {"NSAllowsLocalNetworking": True}) self.assertNotIn("NSAppTransportSecurity", release) + def test_simulator_keychain_identity_never_changes_physical_entitlements(self): + simulator = plistlib.loads((ROOT / "SpaceWorks/Generated/Simulator.entitlements").read_bytes()) + brand = json.loads((ROOT / "branding/brand.json").read_text()) + identity = "LOCALCI." + brand["ios_bundle_id"] + self.assertEqual(simulator["application-identifier"], identity) + self.assertEqual(simulator["keychain-access-groups"], [identity]) + for configuration in ("Debug", "Release"): + real = plistlib.loads((ROOT / f"SpaceWorks/Generated/Attestation-{configuration}.entitlements").read_bytes()) + self.assertNotIn("application-identifier", real) + self.assertNotIn("keychain-access-groups", real) + if __name__ == "__main__": unittest.main() From f7f414e8c277aef67545065b19d5430a439039dc Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:21:29 +0530 Subject: [PATCH 07/98] test(a1): isolate saved makerspace selection between UI journeys Co-Authored-By: Codex --- .github/workflows/ios.yml | 2 +- SpaceWorks/SpaceWorksApp.swift | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 2ffcfcf..2e7eb3c 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -6,7 +6,7 @@ on: permissions: contents: read concurrency: - group: ios-${{ github.ref }} + group: ios-${{ github.event_name == 'workflow_dispatch' && format('full-{0}', github.run_id) || format('{0}-{1}', github.event.pull_request.head.repo.full_name || github.repository, github.head_ref || github.ref_name) }} cancel-in-progress: true jobs: build-and-test: diff --git a/SpaceWorks/SpaceWorksApp.swift b/SpaceWorks/SpaceWorksApp.swift index b0ea6a7..3a3562c 100644 --- a/SpaceWorks/SpaceWorksApp.swift +++ b/SpaceWorks/SpaceWorksApp.swift @@ -9,6 +9,7 @@ struct SpaceWorksApp: App { if ProcessInfo.processInfo.arguments.contains("--ui-testing") { UserDefaults.standard.removeObject(forKey: "server-origin") UserDefaults.standard.removeObject(forKey: "dark-mode") + UserDefaults.standard.removeObject(forKey: "makerspace-selection") if let origin = try? ServerOrigin("https://ui-test.spaceworks.invalid") { try? TokenVault(origin: origin).store(nil) } } #endif From da24b6070e87bde8ff8397405ba8aef3348eb082 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:43:19 +0530 Subject: [PATCH 08/98] feat(ux): add branded onboarding and native account recovery flows Complete optional introduction, shared motion, native password controls, generated signup/reset/email operations, accessible provider previews and durable recovery deadlines after A1 CI passed. Co-Authored-By: Codex --- .../Sources/SpaceWorksAPI/Networking.swift | 22 +++ .../openapi-generator-config.yaml | 5 + SpaceWorks/AccountAccessState.swift | 139 ++++++++++++++++++ SpaceWorks/AccountAccessView.swift | 120 +++++++++++++++ SpaceWorks/AuthState.swift | 45 ++++-- SpaceWorks/AuthView.swift | 103 ++++++++----- SpaceWorks/DemoMode.swift | 75 ++++++---- SpaceWorks/DemoProviderState.swift | 38 +++++ SpaceWorks/IntroductionView.swift | 52 +++++++ SpaceWorks/PasswordEntry.swift | 81 ++++++++++ SpaceWorks/ProviderButtons.swift | 43 ++++++ SpaceWorks/Resources/Fonts/OFL-Unbounded.txt | 93 ++++++++++++ SpaceWorks/Resources/Fonts/README.md | 11 ++ .../Resources/Fonts/Unbounded-Variable.ttf | Bin 0 -> 469444 bytes SpaceWorks/RootView.swift | 35 ++++- SpaceWorks/SpaceWorksApp.swift | 15 ++ SpaceWorksTests/AccountAccessTests.swift | 96 ++++++++++++ SpaceWorksTests/AccountNetworkingTests.swift | 48 ++++++ SpaceWorksTests/AuthTests.swift | 10 +- SpaceWorksTests/NativeBrandTests.swift | 8 + SpaceWorksUITests/MainFlowTests.swift | 46 +++++- design | 2 +- docs/CHANGELOG.md | 16 +- docs/IMPLEMENTATION-PLAN.md | 6 + scripts/generate.py | 14 ++ scripts/generate_motion.py | 28 ++++ scripts/tests/test_motion.py | 39 +++++ 27 files changed, 1102 insertions(+), 88 deletions(-) create mode 100644 SpaceWorks/AccountAccessState.swift create mode 100644 SpaceWorks/AccountAccessView.swift create mode 100644 SpaceWorks/DemoProviderState.swift create mode 100644 SpaceWorks/IntroductionView.swift create mode 100644 SpaceWorks/PasswordEntry.swift create mode 100644 SpaceWorks/ProviderButtons.swift create mode 100644 SpaceWorks/Resources/Fonts/OFL-Unbounded.txt create mode 100644 SpaceWorks/Resources/Fonts/README.md create mode 100644 SpaceWorks/Resources/Fonts/Unbounded-Variable.ttf create mode 100644 SpaceWorksTests/AccountAccessTests.swift create mode 100644 SpaceWorksTests/AccountNetworkingTests.swift create mode 100644 SpaceWorksTests/NativeBrandTests.swift create mode 100644 scripts/generate_motion.py create mode 100644 scripts/tests/test_motion.py diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift index 5a1e4a9..b344a02 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift @@ -70,6 +70,11 @@ public struct APIError: Error, LocalizedError, Sendable, Equatable { if let error = error as? ClientError { return status(error.underlyingError) } return nil } + public static func code(_ error: Error) -> String? { + if let error = error as? APIError { return error.code } + if let error = error as? ClientError { return code(error.underlyingError) } + return nil + } } /// Local credentials, not a hand-written API payload. Persist together in Keychain in A1. @@ -300,4 +305,21 @@ public final class SpaceWorksService: Sendable { public func changePassword(current: String, new: String) async throws { _ = try await client.api_v1_auth_change_password_create(.init(body: .json(.init(current_password: current, new_password: new)))).ok.body.json } + public func createAccount(name: String, email: String, password: String, phone: String) async throws { + let input = Components.Schemas.MemberSignUp(display_name: name, email: email, phone: phone, password: password, website: "") + _ = try await publicClient.api_v1_auth_member_sign_up_create(.init(body: .json(input))).ok.body.json + } + public func requestPasswordReset(email: String) async throws { + _ = try await publicClient.api_v1_auth_forgot_password_create(.init(body: .json(.init(email: email)))).ok.body.json + } + public func resetPassword(email: String, code: String, new: String) async throws { + let input = Components.Schemas.ResetPasswordConfirmRequest.OtpResetPasswordConfirm(.init(email: email, code: code, new_password: new)) + _ = try await publicClient.api_v1_auth_reset_password_create(.init(body: .json(input))).ok.body.json + } + public func confirmEmail(code: String) async throws { + _ = try await client.api_v1_auth_email_verification_confirm_create(.init(body: .json(.init(code: code)))).ok.body.json + } + public func resendEmailVerification() async throws { + _ = try await client.api_v1_auth_email_verification_resend_create().ok.body.json + } } diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml index 9065253..f3a3178 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml @@ -15,3 +15,8 @@ filter: - api_v1_auth_mfa_confirm_create - api_v1_config_retrieve - api_v1_auth_change_password_create + - api_v1_auth_member_sign_up_create + - api_v1_auth_forgot_password_create + - api_v1_auth_reset_password_create + - api_v1_auth_email_verification_confirm_create + - api_v1_auth_email_verification_resend_create diff --git a/SpaceWorks/AccountAccessState.swift b/SpaceWorks/AccountAccessState.swift new file mode 100644 index 0000000..a7afe19 --- /dev/null +++ b/SpaceWorks/AccountAccessState.swift @@ -0,0 +1,139 @@ +import Foundation +import Observation +import SpaceWorksAPI + +@MainActor @Observable +final class AccountAccessCooldown { + var resendAfter = Date.distantPast + var submitAfter = Date.distantPast + private static var scopes: [String: AccountAccessCooldown] = [:] + static func scoped(_ key: String) -> AccountAccessCooldown { + if let value = scopes[key] { return value } + let value = AccountAccessCooldown(); scopes[key] = value; return value + } +} + +/// Task-local account forms. Closures make offline/demo previews independent of production transport. +@MainActor @Observable +final class AccountAccessState: Identifiable { + let id = UUID() + enum Step: Equatable { case signUp, created, requestReset, confirmReset, resetComplete, unavailable, emailVerification } + var step: Step + var name = "" + var email = "" + var phone = "" + var password = "" + var confirmation = "" + var code = "" + private(set) var busy = false + private(set) var error: String? + private(set) var notice: String? + private(set) var resendAfter: Date { + get { cooldown.resendAfter } + set { cooldown.resendAfter = newValue } + } + private(set) var submitAfter: Date { + get { cooldown.submitAfter } + set { cooldown.submitAfter = newValue } + } + private let cooldown: AccountAccessCooldown + private let create: (String, String, String, String) async throws -> Void + private let request: (String) async throws -> Void + private let reset: (String, String, String) async throws -> Void + private let verify: (String) async throws -> Void + private let resendVerification: () async throws -> Void + private let now: () -> Date + + init(step: Step, now: @escaping () -> Date = Date.init, cooldown: AccountAccessCooldown? = nil, + create: @escaping (String, String, String, String) async throws -> Void, + request: @escaping (String) async throws -> Void, + reset: @escaping (String, String, String) async throws -> Void, + verify: @escaping (String) async throws -> Void = { _ in }, + resendVerification: @escaping () async throws -> Void = {}) { + self.step = step; self.now = now; self.create = create; self.request = request + self.reset = reset; self.verify = verify; self.resendVerification = resendVerification + self.cooldown = cooldown ?? AccountAccessCooldown() + } + var canResend: Bool { !busy && now() >= resendAfter } + func signUp() async { + guard !busy, now() >= submitAfter else { return } + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed.count <= 200 else { error = "Enter your name (up to 200 characters)."; return } + guard validEmail else { error = "Enter a valid email address."; return } + guard validatePasswords() else { return } + guard password.count <= 128 else { error = "Use a password with no more than 128 characters."; return } + guard phone.count <= 32 else { error = "Use a phone number with no more than 32 characters."; return } + await perform { + try await self.create(trimmed, self.email.trimmingCharacters(in: .whitespacesAndNewlines), self.password, self.phone) + self.clearSecrets(); self.step = .created + } + } + func requestCode(resending: Bool = false) async { + guard !busy, now() >= resendAfter, now() >= submitAfter else { return } + guard validEmail else { error = "Enter a valid email address."; return } + await perform { + try await self.request(self.email.trimmingCharacters(in: .whitespacesAndNewlines)) + self.resendAfter = self.now().addingTimeInterval(60) + self.notice = "If an account matches this email, a recovery code will arrive. Delivery may take a few minutes." + self.step = .confirmReset + } + } + func confirmReset() async { + guard !busy, now() >= submitAfter else { return } + guard isSixDigits else { error = "Enter the six-digit verification code."; return } + guard validatePasswords() else { return } + busy = true; error = nil + defer { busy = false } + do { + try await reset(email.trimmingCharacters(in: .whitespacesAndNewlines), code, password) + clearSecrets(); step = .resetComplete + } catch { + // Every server rejection is identical: don't expose an OTP/account-existence oracle. + self.error = "Invalid or expired verification code." + preserveCooldown(error) + } + } + func confirmEmail() async { + guard !busy, now() >= submitAfter else { return } + guard isSixDigits else { error = "Enter the six-digit verification code."; return } + await perform { try await self.verify(self.code); self.code = ""; self.notice = "Email confirmed." } + } + func resendEmail() async { + guard canResend, now() >= submitAfter else { return } + await perform { + try await self.resendVerification() + self.resendAfter = self.now().addingTimeInterval(60) + self.notice = "Check your email for a verification code." + } + } + func changeEmail() { + guard !busy else { return } + clearSecrets(); error = nil; notice = nil; step = .requestReset + // Operation cooldown deliberately survives editing and navigation. + } + func clearSecrets() { password = ""; confirmation = ""; code = "" } + private var isSixDigits: Bool { code.count == 6 && code.allSatisfy { $0.isASCII && $0.isNumber } } + private var validEmail: Bool { + let value = email.trimmingCharacters(in: .whitespacesAndNewlines) + return value.count <= 254 && value.range(of: #"^[^\s@]+@[^\s@]+\.[^\s@]+$"#, options: .regularExpression) != nil + } + private func validatePasswords() -> Bool { + guard password.count >= 8 else { error = "Use at least 8 characters."; return false } + guard password == confirmation else { error = "Passwords don't match."; return false } + return true + } + private func preserveCooldown(_ failure: Error) { + if let deadline = APIError.retryDeadline(failure) { + submitAfter = max(submitAfter, deadline); resendAfter = max(resendAfter, deadline) + } + } + private func perform(_ operation: () async throws -> Void) async { + busy = true; error = nil + defer { busy = false } + do { try await operation() } catch { + preserveCooldown(error) + if APIError.code(error) == "recovery_unavailable" { step = .unavailable; clearSecrets() } + else { self.error = APIError.display(error) } + } + } +} diff --git a/SpaceWorks/AccountAccessView.swift b/SpaceWorks/AccountAccessView.swift new file mode 100644 index 0000000..211a97e --- /dev/null +++ b/SpaceWorks/AccountAccessView.swift @@ -0,0 +1,120 @@ +import SwiftUI + +struct AccountAccessView: View { + @Bindable var form: AccountAccessState + let close: () -> Void + @FocusState private var focused: String? + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + BrandWordmark() + SWCard { + Text(heading).font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text(explanation).foregroundStyle(Color.swMuted) + switch form.step { + case .signUp: + input("Name", value: $form.name, id: "signup-name", type: .name) + input("Email", value: $form.email, id: "account-email", type: .emailAddress) + PasswordControl(title: "Password", text: $form.password, newPassword: true, identifier: "account-password") + PasswordControl(title: "Confirm password", text: $form.confirmation, newPassword: true, identifier: "account-confirm-password") + DisclosureGroup("Phone number (optional)") { + input("Phone number", value: $form.phone, id: "account-phone", type: .telephoneNumber) + }.padding(.vertical, 8) + primary("Create account") { await form.signUp() } + case .requestReset: + input("Email", value: $form.email, id: "account-email", type: .emailAddress) + primary("Request recovery code") { await form.requestCode() } + case .confirmReset: + Text(form.email).textSelection(.enabled).foregroundStyle(Color.swMuted) + codeInput("Six-digit code") + PasswordControl(title: "New password", text: $form.password, newPassword: true, identifier: "account-password") + PasswordControl(title: "Confirm password", text: $form.confirmation, newPassword: true, identifier: "account-confirm-password") + primary("Update password") { await form.confirmReset() } + resend("Request another code") { await form.requestCode(resending: true) } + Button("Change email") { form.changeEmail() }.frame(minHeight: 48) + case .emailVerification: + Text(form.email).textSelection(.enabled).foregroundStyle(Color.swMuted) + codeInput("Verification code") + primary("Confirm email") { await form.confirmEmail() } + resend("Send another code") { await form.resendEmail() } + case .created, .resetComplete, .unavailable: + Image(systemName: form.step == .unavailable ? "person.crop.circle.badge.questionmark" : "checkmark.circle") + .font(.largeTitle).foregroundStyle(Color.swAccentInk).accessibilityHidden(true) + Button(form.step == .unavailable ? "Back to sign in" : "Go to sign in", action: close).buttonStyle(SWPrimary()) + } + if let error = form.error { SWError(message: error) } + if let notice = form.notice, form.step != .confirmReset { + Label(notice, systemImage: "info.circle").foregroundStyle(Color.swMuted) + } + if ![.created, .resetComplete, .unavailable].contains(form.step) { + Button(form.step == .emailVerification ? "Done" : "Back to sign in", action: close) + .frame(minHeight: 48).disabled(form.busy) + } + }.disabled(form.busy) + }.padding(24).frame(maxWidth: 508).frame(maxWidth: .infinity) + }.background(Color.swBg).scrollDismissesKeyboard(.interactively) + .interactiveDismissDisabled(form.busy) + .onDisappear { form.clearSecrets() } + } + private var heading: String { + switch form.step { + case .signUp: return "Create your account" + case .created: return "Account created" + case .requestReset: return "Reset password" + case .confirmReset: return "Enter your recovery code" + case .resetComplete: return "Password updated" + case .unavailable: return "Password recovery unavailable" + case .emailVerification: return "Confirm your email" + } + } + private var explanation: String { + switch form.step { + case .signUp: return "Use the email you want to use with your makerspace." + case .created: return "Sign in to continue. You may need to confirm your email." + case .requestReset: return "Enter your account email to request a six-digit recovery code." + case .confirmReset: return "If an account matches this email, a recovery code will arrive. Delivery may take a few minutes." + case .resetComplete: return "Your password has been updated. You can now sign in." + case .unavailable: return "Contact your makerspace staff to reset your password." + case .emailVerification: return "Enter the six-digit code sent to your email." + } + } + private func input(_ title: String, value: Binding, id: String, type: UITextContentType) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title).font(.headline) + TextField(title, text: value, prompt: Text(title).foregroundColor(Color.swMuted)).textContentType(type) + .keyboardType(type == .emailAddress ? .emailAddress : type == .telephoneNumber ? .phonePad : .default) + .textInputAutocapitalization(type == .name ? .words : .never).autocorrectionDisabled() + .focused($focused, equals: id).accessibilityIdentifier(id) + .padding(14).frame(minHeight: 48).background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)) + } + } + private func codeInput(_ title: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title).font(.headline) + TextField(title, text: $form.code, prompt: Text(title).foregroundColor(Color.swMuted)).keyboardType(.numberPad).textContentType(.oneTimeCode) + .textInputAutocapitalization(.never).autocorrectionDisabled().monospacedDigit() + .accessibilityIdentifier("account-code").padding(14).frame(minHeight: 48) + .background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)) + } + } + private func primary(_ title: String, action: @escaping () async -> Void) -> some View { + TimelineView(.periodic(from: .now, by: 1)) { tick in + Button(form.busy ? "Please wait…" : title) { focused = nil; Task { await action() } } + .buttonStyle(SWPrimary()).disabled(form.busy || tick.date < form.submitAfter) + } + } + private func resend(_ title: String, action: @escaping () async -> Void) -> some View { + TimelineView(.periodic(from: .now, by: 1)) { tick in + VStack(alignment: .leading, spacing: 4) { + Button(title) { Task { await action() } }.frame(minHeight: 48) + .disabled(form.busy || tick.date < form.resendAfter) + if tick.date < form.resendAfter { + Text("Try again in \(max(1, Int(ceil(form.resendAfter.timeIntervalSince(tick.date)))))s.") + .font(.caption).foregroundStyle(Color.swMuted) + } + } + } + } +} diff --git a/SpaceWorks/AuthState.swift b/SpaceWorks/AuthState.swift index f5aea1a..137a6ae 100644 --- a/SpaceWorks/AuthState.swift +++ b/SpaceWorks/AuthState.swift @@ -24,6 +24,7 @@ final class AuthState { private let storage: any TokenStorage private var restored = false private var mfaExpires: Date? + private let serverScope: String init(origin: ServerOrigin, session: URLSession = DeploymentSession.make(), settings: UserDefaults = .standard, storage: (any TokenStorage)? = nil, @@ -32,6 +33,7 @@ final class AuthState { self.storage = storage self.attest = attest self.settings = settings + self.serverScope = origin.url.absoluteString var pair: TokenPair? var storageError: String? do { pair = try storage.load() } catch { storageError = APIError.display(error) } @@ -45,14 +47,37 @@ final class AuthState { var selectedMembership: Components.Schemas.AuthMembership? { memberships.first { $0.id == selectedID } } var displayName: String { user?.display_name.isEmpty == false ? user!.display_name : user?.username ?? "" } var passwordEnabled: Bool { + guard configuration != nil else { return false } + return configFlag("password_login") + } + var signupEnabled: Bool { configuration != nil && configFlag("member_accounts") && configFlag("self_registration") } + private func configFlag(_ key: String) -> Bool { guard let configuration, let data = try? JSONEncoder().encode(configuration), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let login = object["password_login"] as? [String: Any] else { return true } + let login = object[key] as? [String: Any] else { return true } // PublicConfig's extensible object; same documented switch consumed by MemberAuthPanel. return login["enabled"] as? Bool != false } // Fail closed until the schema supplies native_staff authority (backend 1a). var staffToolsAvailable: Bool { false } + private var selectionKey: String { "makerspace-selection.\(serverScope).\(user?.id ?? 0)" } + func retryRestore() async { + guard !busy else { return } + busy = true; defer { busy = false } + restored = false; error = nil; await restore() + } + func accountForm(_ step: AccountAccessState.Step) -> AccountAccessState { + let service = service + let task = step == .signUp ? "signup" : step == .emailVerification ? "email" : "password-recovery" + let form = AccountAccessState(step: step, cooldown: AccountAccessCooldown.scoped(serverScope + "." + task), + create: { try await service.createAccount(name: $0, email: $1, password: $2, phone: $3) }, + request: { try await service.requestPasswordReset(email: $0) }, + reset: { try await service.resetPassword(email: $0, code: $1, new: $2) }, + verify: { [weak self] code in try await service.confirmEmail(code: code); await self?.foreground() }, + resendVerification: { try await service.resendEmailVerification() }) + form.email = user?.email ?? username + return form + } func restore() async { guard !restored else { return } @@ -123,7 +148,7 @@ final class AuthState { do { try await service.makerspace.select(id, activeMemberships: Set(memberships.map(\.id))) selectedID = id - settings.set(id, forKey: "makerspace-selection") + settings.set(id, forKey: selectionKey) } catch { fail(error) } } func foreground() async { @@ -157,11 +182,12 @@ final class AuthState { defer { busy = false } do { try await service.changePassword(current: current, new: new) - try? await service.signOut() - try await service.refresh.install(nil) - await clear() - error = "Password updated. Sign in again." - } catch { fail(error) } + try await loadIdentity() + if stage == .passwordChange { error = "Your account still requires a password update. Try again or contact your makerspace." } + } catch { + fail(error) + if isUnauthorized(error) { try? await service.refresh.install(nil); await clear() } + } } private func accept(_ pair: TokenPair) async throws { guard !pair.access.isEmpty, !pair.refresh.isEmpty else { @@ -182,7 +208,7 @@ final class AuthState { } user = identity let ids = Set(memberships.map(\.id)) - let saved = selectedID ?? settings.object(forKey: "makerspace-selection") as? Int + let saved = selectedID ?? settings.object(forKey: selectionKey) as? Int let selection = saved.flatMap { ids.contains($0) ? $0 : nil } ?? (memberships.count == 1 ? memberships.first?.id : nil) try await service.makerspace.select(selection, activeMemberships: ids) selectedID = selection @@ -190,8 +216,9 @@ final class AuthState { } private func clear() async { try? await service.makerspace.select(nil, activeMemberships: []) + settings.removeObject(forKey: selectionKey) user = nil; selectedID = nil; mfa = nil; enrollment = nil; recoveryCodes = [] - password = ""; code = ""; settings.removeObject(forKey: "makerspace-selection"); stage = .signIn + password = ""; code = ""; stage = .signIn } private func fail(_ failure: Error) { guard !(failure is CancellationError) else { return } diff --git a/SpaceWorks/AuthView.swift b/SpaceWorks/AuthView.swift index b7ddabf..6ba8a9d 100644 --- a/SpaceWorks/AuthView.swift +++ b/SpaceWorks/AuthView.swift @@ -1,6 +1,7 @@ import SwiftUI import SpaceWorksAPI import CoreImage.CIFilterBuiltins +import UniformTypeIdentifiers struct AuthRoot: View { let state: AppState @@ -36,18 +37,15 @@ struct PasswordChangeView: View { ScrollView { SWCard { Text("Choose a new password").font(.largeTitle.bold()) - Text("Update your temporary password before opening your makerspace.").foregroundStyle(Color.swMuted) - Text("Current password").font(.headline) - SecureField("Current password", text: $current).textContentType(.password).padding(14).background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) - Text("New password").font(.headline) - SecureField("New password", text: $new).textContentType(.newPassword).padding(14).background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) - Text("Confirm new password").font(.headline) - SecureField("Confirm new password", text: $confirmation).textContentType(.newPassword).padding(14).background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) + Text("Replace your temporary password to continue.").foregroundStyle(Color.swMuted) + PasswordControl(title: "Current password", text: $current, identifier: "current-password") + PasswordControl(title: "New password", text: $new, newPassword: true, identifier: "new-password") + PasswordControl(title: "Confirm password", text: $confirmation, newPassword: true, identifier: "confirm-password") if let error = auth.error { SWError(message: error) } Button(auth.busy ? "Updating…" : "Update password") { Task { await auth.changePassword(current: current, new: new, confirmation: confirmation); current = ""; new = ""; confirmation = "" } }.buttonStyle(SWPrimary()).disabled(auth.busy) - Button("Sign out") { Task { await auth.signOut() } }.frame(minHeight: 44).disabled(auth.busy) + Button("Sign out") { Task { await auth.signOut() } }.frame(minHeight: 48).disabled(auth.busy) }.padding(24).frame(maxWidth: 560).frame(maxWidth: .infinity) }.background(Color.swBg) } @@ -65,9 +63,11 @@ struct SWCard: View { struct SWError: View { let message: String var body: some View { - Label(message, systemImage: "exclamationmark.circle.fill") + Label { Text(message).fixedSize(horizontal: false, vertical: true) } icon: { Image(systemName: "exclamationmark.circle.fill") } .foregroundStyle(Color.swDanger).accessibilityIdentifier("auth-error") .accessibilityAddTraits(.updatesFrequently) + .onAppear { UIAccessibility.post(notification: .announcement, argument: message) } + .onChange(of: message) { _, value in UIAccessibility.post(notification: .announcement, argument: value) } } } struct SWPrimary: ButtonStyle { @@ -81,52 +81,58 @@ struct SignInView: View { @Bindable var auth: AuthState let state: AppState @State private var showPassword = false - @FocusState private var passwordFocused: Bool + @State private var passwordFocused = false + @State private var accountForm: AccountAccessState? @Environment(\.dynamicTypeSize) private var typeSize var body: some View { GeometryReader { geometry in ScrollView { HStack(alignment: .center, spacing: 40) { VStack(alignment: .leading, spacing: 24) { - BrandWordmark() + BrandWordmark(compact: geometry.size.height < 600) Text("Welcome back.").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) - Text("Your makerspace, in your pocket.").foregroundStyle(Color.swMuted) + Text("Sign in to your makerspace account.").foregroundStyle(Color.swMuted) SWCard { Text("Sign in").font(.title2.bold()) - if !auth.passwordEnabled { - Text("This deployment signs in through an identity provider. Ask your makerspace to enable native sign-in.") - .foregroundStyle(Color.swMuted) + if auth.configuration == nil { + Text("Couldn't load sign-in options. Try again.").foregroundStyle(Color.swMuted) + if let error = auth.error { SWError(message: error) } + Button("Try again") { Task { await auth.retryRestore() } }.buttonStyle(SWPrimary()) + } else if !auth.passwordEnabled { + Text("Sign-in isn't available here").font(.headline) + Text("Ask your makerspace for help, or check the server address.").foregroundStyle(Color.swMuted) } else { field("Email or username") { - TextField("Email or username", text: $auth.username).textContentType(.username) + TextField("Email or username", text: $auth.username, + prompt: Text("Email or username").foregroundColor(Color.swMuted)).textContentType(.username) .textInputAutocapitalization(.never).autocorrectionDisabled() .submitLabel(.next).onSubmit { passwordFocused = true }.accessibilityIdentifier("username") } field("Password") { HStack { - Group { - if showPassword { TextField("Password", text: $auth.password) } - else { SecureField("Password", text: $auth.password) } - }.textContentType(.password).focused($passwordFocused).submitLabel(.done) - .onSubmit { passwordFocused = false }.accessibilityIdentifier("password") + PasswordEntry(text: $auth.password, visible: showPassword, identifier: "password", focus: $passwordFocused) Button { showPassword.toggle() } label: { Image(systemName: showPassword ? "eye.slash" : "eye") } - .accessibilityLabel(showPassword ? "Hide password" : "Show password").frame(minWidth: 44, minHeight: 44) + .accessibilityLabel(showPassword ? "Hide password" : "Show password").frame(minWidth: 48, minHeight: 48) } } + Button("Forgot password?") { accountForm = auth.accountForm(.requestReset) }.frame(minHeight: 48) if let error = auth.error { SWError(message: error) } TimelineView(.periodic(from: .now, by: 1)) { context in Button(auth.busy ? "Signing in…" : "Sign in") { Task { await auth.signIn() } } .buttonStyle(SWPrimary()).disabled(auth.busy || (auth.retryAfter ?? .distantPast) > context.date) .accessibilityIdentifier("sign-in") } + if auth.signupEnabled { + HStack { Text("New here?").foregroundStyle(Color.swMuted); Button("Create account") { accountForm = auth.accountForm(.signUp) }.frame(minHeight: 48) } + } } }.disabled(auth.busy) - if !state.locked { Button("Change server") { state.changeServer() }.frame(minHeight: 44).disabled(auth.busy) } + if !state.locked { Button("Change server") { state.changeServer() }.frame(minHeight: 48).disabled(auth.busy) } }.frame(maxWidth: 460) - if geometry.size.width >= 900 && !typeSize.isAccessibilitySize { + if geometry.size.width >= 960 && geometry.size.height >= 600 && !typeSize.isAccessibilitySize { VStack(alignment: .leading, spacing: 24) { - Image(systemName: "wrench.and.screwdriver").font(.system(size: 64)).foregroundStyle(Color.swInk) - Text("Less searching.\nMore making.").font(.largeTitle.bold()) + BrandWordmark(spacious: true) + Text("Less searching.\nMore making.").font(.custom("Unbounded-Regular", size: 24, relativeTo: .title2).weight(.medium)) Text("Borrow hardware. Make room for your next idea.").font(.title3) }.padding(40).frame(maxWidth: 460, minHeight: 460, alignment: .leading) .background(Color.swSuccess, in: RoundedRectangle(cornerRadius: 28)).foregroundStyle(Color.swOnSuccess) @@ -135,6 +141,7 @@ struct SignInView: View { }.padding(24).frame(maxWidth: .infinity, minHeight: geometry.size.height) }.background(Color.swBg).scrollDismissesKeyboard(.interactively) } + .sheet(item: $accountForm) { form in AccountAccessView(form: form) { auth.username = form.email; accountForm = nil } } } private func field(_ title: String, @ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 8) { @@ -147,16 +154,27 @@ struct SignInView: View { } struct BrandWordmark: View { + var compact = false + var spacious = false + @Environment(\.verticalSizeClass) private var verticalSize + @Environment(\.dynamicTypeSize) private var typeSize var body: some View { if Brand.appName == "Space Works" { - Image("Wordmark").resizable().scaledToFit().frame(maxWidth: 180, maxHeight: 64) - .padding(.vertical, 16).accessibilityLabel("Space Works") + if compact || verticalSize == .compact || typeSize.isAccessibilitySize { + Image("BrandSymbol").resizable().scaledToFit().frame(width: 52, height: 48) + .padding(12).accessibilityLabel("Space Works") + } else { + Image("BrandStacked").resizable().scaledToFit().frame(width: spacious ? 280 : 180, height: spacious ? 100 : 64) + .padding(16).accessibilityLabel("Space Works") + } } else { Text(Brand.appName).font(.headline).foregroundStyle(Color.swAccentInk) } } } struct MFAView: View { @Bindable var auth: AuthState + @State private var recoveryEntry = false + @State private var codesSaved = false var body: some View { ScrollView { SWCard { @@ -164,25 +182,34 @@ struct MFAView: View { if auth.stage == .recoveryCodes { Text("Save your recovery codes").font(.largeTitle.bold()) Text("Keep these somewhere safe. Each code works once.").foregroundStyle(Color.swMuted) - ForEach(auth.recoveryCodes, id: \.self) { Text($0).font(.body.monospaced()).textSelection(.enabled) } - Button("I saved my codes. Continue") { auth.confirmRecoveryCodes() }.buttonStyle(SWPrimary()) + ForEach(auth.recoveryCodes, id: \.self) { Text($0).monospacedDigit().textSelection(.enabled) } + Toggle("I've saved my recovery codes", isOn: $codesSaved).frame(minHeight: 48) + Button("Continue") { auth.confirmRecoveryCodes() }.buttonStyle(SWPrimary()).disabled(!codesSaved) } else { - Text(auth.mfa?.enrollment_required == true ? "Set up two-step sign-in" : "Verify your sign-in").font(.largeTitle.bold()) + Text(auth.mfa?.enrollment_required == true ? "Add extra security" : "Check your authenticator").font(.largeTitle.bold()) if let enrollment = auth.enrollment { - Text("Add this account to your authenticator app.").foregroundStyle(Color.swMuted) + Text("Add this account to your authenticator app, then enter its six-digit code.").foregroundStyle(Color.swMuted) QRCodeView(value: enrollment.provisioning_uri) - Text("Can't scan? Enter this key").font(.headline) - Text(enrollment.secret).font(.body.monospaced()).textSelection(.enabled) + DisclosureGroup("Can't scan the code?") { + Text(enrollment.secret).monospacedDigit().textSelection(.enabled) + Button("Copy setup key") { UIPasteboard.general.setItems([[UTType.utf8PlainText.identifier: enrollment.secret]], options: [.localOnly: true, .expirationDate: Date().addingTimeInterval(60)]) }.frame(minHeight: 48) + } + } else { + Text(recoveryEntry ? "Enter one of your saved recovery codes." : "Enter the six-digit code from your authenticator app.").foregroundStyle(Color.swMuted) } - Text("Authenticator or recovery code").font(.headline) - TextField("Authenticator or recovery code", text: $auth.code).textContentType(.oneTimeCode) + Text(recoveryEntry ? "Recovery code" : "Authentication code").font(.headline) + TextField(recoveryEntry ? "Recovery code" : "Authentication code", text: $auth.code, + prompt: Text(recoveryEntry ? "Recovery code" : "Authentication code").foregroundColor(Color.swMuted)) .textInputAutocapitalization(.never).autocorrectionDisabled().padding(16) .background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)).accessibilityIdentifier("mfa-code") if let error = auth.error { SWError(message: error) } Button(auth.busy ? "Verifying…" : "Verify") { Task { await auth.verify() } } .buttonStyle(SWPrimary()).disabled(auth.busy).accessibilityIdentifier("verify") - Button("Back to sign in") { auth.backToSignIn() }.frame(minHeight: 44).disabled(auth.busy) + if auth.enrollment == nil { + Button(recoveryEntry ? "Use authenticator code" : "Use a recovery code") { recoveryEntry.toggle(); auth.code = "" }.frame(minHeight: 48).disabled(auth.busy) + } + Button("Back to sign in") { auth.backToSignIn() }.frame(minHeight: 48).disabled(auth.busy) } }.padding(24).frame(maxWidth: 560).frame(maxWidth: .infinity) }.background(Color.swBg) @@ -222,7 +249,7 @@ struct MakerspacePicker: View { }.buttonStyle(.plain).accessibilityIdentifier("makerspace-\(membership.id)") } if let error = auth.error { SWError(message: error) } - Button("Sign out") { Task { await auth.signOut() } }.frame(minHeight: 44) + Button("Sign out") { Task { await auth.signOut() } }.frame(minHeight: 48) }.padding(24).frame(maxWidth: 640).frame(maxWidth: .infinity) }.background(Color.swBg) } diff --git a/SpaceWorks/DemoMode.swift b/SpaceWorks/DemoMode.swift index 9cbe065..5e53dfb 100644 --- a/SpaceWorks/DemoMode.swift +++ b/SpaceWorks/DemoMode.swift @@ -56,7 +56,7 @@ struct DemoRoot: View { HStack { Text("Demo mode · sample data").font(.caption.bold()) Spacer() - Button("Exit demo") { session.reset(); exit() }.frame(minHeight: 44) + Button("Exit demo") { session.reset(); exit() }.frame(minHeight: 48) }.padding(.horizontal, 20).foregroundStyle(Color.swOnEmphasis).background(Color.swEmphasis) if session.stage == .shell { DemoShell(session: session) } else { DemoAuthView(session: session) } @@ -66,8 +66,9 @@ struct DemoRoot: View { struct DemoAuthView: View { @Bindable var session: DemoSession - @FocusState private var passwordFocused: Bool + @State private var passwordFocused = false @State private var provider: DemoProvider? + @State private var accountForm: AccountAccessState? var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { @@ -84,27 +85,30 @@ struct DemoAuthView: View { case .signIn: Text("Welcome back.").font(.largeTitle.bold()) Text("Your makerspace, in your pocket.").foregroundStyle(Color.swMuted) - ForEach(DemoProvider.allCases.filter { $0 != .phone || session.role == .member }) { choice in + GeometryReader { geometry in + GoogleProviderButton(wide: geometry.size.width >= 312) { provider = .google } + }.frame(height: 48) + AppleProviderButton { provider = .apple }.frame(height: 48) + ForEach(DemoProvider.allCases.filter { $0 == .organization || ($0 == .phone && session.role == .member) }) { choice in Button("Continue with \(choice.rawValue)") { provider = choice } .frame(maxWidth: .infinity, minHeight: 48) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)) } Text("or with your email").font(.caption).foregroundStyle(Color.swMuted) Text("Email or username").font(.headline) - TextField("Email or username", text: $session.username).textInputAutocapitalization(.never) + TextField("Email or username", text: $session.username, prompt: Text("Email or username").foregroundColor(Color.swMuted)).textInputAutocapitalization(.never) .autocorrectionDisabled().submitLabel(.next).onSubmit { passwordFocused = true } .demoField().accessibilityIdentifier("demo-username") - Text("Password").font(.headline) - SecureField("Password", text: $session.password).focused($passwordFocused) - .submitLabel(.done).onSubmit { passwordFocused = false } - .demoField().accessibilityIdentifier("demo-password") + PasswordControl(title: "Password", text: $session.password, identifier: "demo-password", focus: $passwordFocused) Text("Use any email and password in this demo.").font(.caption).foregroundStyle(Color.swMuted) if let error = session.error { SWError(message: error) } TimelineView(.periodic(from: .now, by: 1)) { context in Button("Sign in") { session.signIn() }.buttonStyle(SWPrimary()).accessibilityIdentifier("demo-sign-in") .disabled((session.retryAfter ?? .distantPast) > context.date) } - Button("Back") { session.reset() }.frame(minHeight: 44) + Button("Forgot password?") { accountForm = demoAccountForm(.requestReset) }.frame(minHeight: 48) + Button("Create account") { accountForm = demoAccountForm(.signUp) }.frame(minHeight: 48) + Button("Back") { session.reset() }.frame(minHeight: 48) case .mfa: Text(session.scenario == .enrollment ? "Set up two-step sign-in" : "Verify your sign-in").font(.largeTitle.bold()) if session.scenario == .enrollment { @@ -118,7 +122,7 @@ struct DemoAuthView: View { Text("Use 123456 or sample-recovery in this demo.").font(.caption).foregroundStyle(Color.swMuted) if let error = session.error { SWError(message: error) } Button("Verify") { session.verify() }.buttonStyle(SWPrimary()) - Button("Back to sign in") { session.stage = .signIn; session.error = nil }.frame(minHeight: 44) + Button("Back to sign in") { session.stage = .signIn; session.error = nil }.frame(minHeight: 48) case .recovery: Text("Save your recovery codes").font(.largeTitle.bold()) Text("Sample codes for this demo. Real codes appear once after enrollment.").foregroundStyle(Color.swMuted) @@ -145,15 +149,19 @@ struct DemoAuthView: View { }.padding(24).frame(maxWidth: 600).frame(maxWidth: .infinity) }.background(Color.swBg).scrollDismissesKeyboard(.interactively) .sheet(item: $provider) { DemoProviderView(session: session, provider: $0) } + .sheet(item: $accountForm) { form in AccountAccessView(form: form) { session.username = form.email; accountForm = nil } } + } + private func demoAccountForm(_ step: AccountAccessState.Step) -> AccountAccessState { + AccountAccessState(step: step, create: { _, _, _, _ in }, request: { _ in }, reset: { _, code, _ in + if code != "123456" { throw NSError(domain: "DemoReset", code: 1) } + }) } } struct DemoProviderView: View { let session: DemoSession let provider: DemoProvider @Environment(\.dismiss) private var dismiss - @State private var phone = "" - @State private var code = "" - @State private var error: String? + @State private var state = DemoProviderState() var body: some View { NavigationStack { ScrollView { @@ -161,21 +169,36 @@ struct DemoProviderView: View { Text("Continue with \(provider.rawValue)").font(.largeTitle.bold()) Text("Demo only. No provider account is connected and no message is sent.").foregroundStyle(Color.swMuted) if provider == .phone { - Text("Phone number").font(.headline) - TextField("Phone number", text: $phone).textContentType(.telephoneNumber).keyboardType(.phonePad).demoField() - Text("Verification code").font(.headline) - TextField("Verification code", text: $code).textContentType(.oneTimeCode).demoField() - Text("Use 123456 in this demo.").font(.caption).foregroundStyle(Color.swMuted) + if state.step == .phone { + Text("We'll send a verification code.").foregroundStyle(Color.swMuted) + Picker("Country code", selection: $state.country) { Text("India +91").tag("+91"); Text("United States +1").tag("+1"); Text("United Kingdom +44").tag("+44") }.frame(minHeight: 48) + Text("Phone number").font(.headline) + TextField("Phone number", text: $state.phone).textContentType(.telephoneNumber).keyboardType(.phonePad).demoField() + Button("Send verification code") { state.sendCode() }.buttonStyle(SWPrimary()) + .disabled(state.resendAfter > Date()) + } else { + Text("Enter your verification code").font(.title2.bold()) + Text(state.country + " " + state.phone).foregroundStyle(Color.swMuted) + TextField("Verification code", text: $state.code).textContentType(.oneTimeCode).demoField() + Text("Use 123456 in this demo. No message was sent.").font(.caption).foregroundStyle(Color.swMuted) + TimelineView(.periodic(from: .now, by: 1)) { tick in + Button(tick.date < state.resendAfter ? "Resend in \(max(1, Int(ceil(state.resendAfter.timeIntervalSince(tick.date)))))s" : "Send another code") { state.sendCode() } + .frame(minHeight: 48).disabled(tick.date < state.resendAfter) + } + Button("Change number") { state.changeNumber() }.frame(minHeight: 48) + } } else { Label(session.name, systemImage: "person.crop.circle").font(.title2) } - if let error { SWError(message: error) } - Button("Continue demo account") { - if provider == .phone && (phone.isEmpty || code != "123456") { error = "Enter a phone number and the demo code 123456."; return } - session.acceptDemoProvider(); dismiss() - }.buttonStyle(SWPrimary()) + Picker("Preview result", selection: $state.outcome) { ForEach(DemoProviderState.Outcome.allCases, id: \.self) { Text($0.rawValue) } } + if let error = state.error { SWError(message: error) } + if provider != .phone || state.step == .code { + Button(provider == .phone ? "Verify" : "Continue demo account") { + if state.complete(phone: provider == .phone) { session.acceptDemoProvider(); dismiss() } + }.buttonStyle(SWPrimary()) + } }.padding(24).frame(maxWidth: 560).frame(maxWidth: .infinity) - }.background(Color.swBg).toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() }.frame(minHeight: 44) } } + }.background(Color.swBg).toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() }.frame(minHeight: 48) } } } } } @@ -188,7 +211,7 @@ struct DemoShell: View { if sizeClass == .regular { NavigationSplitView { List(AppTab.allCases) { tab in - Button(tab.rawValue) { selected = tab }.frame(minHeight: 44).accessibilityIdentifier("nav-\(tab.rawValue)") + Button(tab.rawValue) { selected = tab }.frame(minHeight: 48).accessibilityIdentifier("nav-\(tab.rawValue)") .listRowBackground(selected == tab ? Color.swSidebarActive : Color.swSidebar) .foregroundStyle(selected == tab ? Color.swOnAccent : Color.swInk) }.scrollContentBackground(.hidden).background(Color.swSidebar).navigationTitle(Brand.appName) @@ -214,7 +237,7 @@ struct DemoDestination: View { SWCard { if tab == .profile { Text(session.role.rawValue).font(.headline) - Button("Sign out") { session.reset() }.frame(minHeight: 44) + Button("Sign out") { session.reset() }.frame(minHeight: 48) } else { Text("Nothing here yet.") } } }.padding(24).frame(maxWidth: 960).frame(maxWidth: .infinity) diff --git a/SpaceWorks/DemoProviderState.swift b/SpaceWorks/DemoProviderState.swift new file mode 100644 index 0000000..3d1fdbb --- /dev/null +++ b/SpaceWorks/DemoProviderState.swift @@ -0,0 +1,38 @@ +#if DEBUG +import Foundation +import Observation + +@MainActor @Observable +final class DemoProviderState { + enum Outcome: String, CaseIterable { case success = "Success", unavailable = "Unavailable", rejected = "Rejected", expired = "Expired code" } + enum Step { case phone, code, account } + var outcome = Outcome.success + var step = Step.phone + var country = "+91" + var phone = "" + var code = "" + private(set) var error: String? + private(set) var resendAfter = Date.distantPast + private let now: () -> Date + init(now: @escaping () -> Date = Date.init) { self.now = now } + func sendCode() { + guard now() >= resendAfter else { return } + guard phone.count >= 6, phone.count <= 14, phone.allSatisfy({ $0.isASCII && $0.isNumber }) else { + error = "Enter a valid phone number."; return + } + code = ""; error = nil; resendAfter = now().addingTimeInterval(60); step = .code + } + func changeNumber() { code = ""; error = nil; step = .phone } + func complete(phone: Bool) -> Bool { + error = nil + switch outcome { + case .unavailable: error = "This sign-in option isn't available on this device."; return false + case .rejected: error = "Sign-in couldn't be completed. Try again."; return false + case .expired: error = "That verification code expired. Request another code."; return false + case .success: + if phone && code != "123456" { error = "That code didn't work. Check it and try again."; return false } + code = ""; return true + } + } +} +#endif diff --git a/SpaceWorks/IntroductionView.swift b/SpaceWorks/IntroductionView.swift new file mode 100644 index 0000000..870ece0 --- /dev/null +++ b/SpaceWorks/IntroductionView.swift @@ -0,0 +1,52 @@ +import SwiftUI + +struct IntroductionView: View { + let finish: () -> Void + @State private var page = 0 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.dynamicTypeSize) private var typeSize + private let headings = ["Make room.", "Borrow with confidence.", "One place for your making."] + private let details = ["Find tools, book a space, and see what's next at your makerspace.", + "Request what you need and keep an eye on your loans and due dates.", + "Keep bookings, event tickets, payments, and updates together."] + private let symbols = [["wrench.and.screwdriver", "shippingbox", "hammer"], + ["list.clipboard", "calendar", "checkmark.seal"], + ["calendar", "ticket", "tray"]] + var body: some View { + GeometryReader { geometry in + ScrollView { + VStack(alignment: .leading, spacing: 24) { + // Brand remains outside the animated content tree at every page transition. + BrandWordmark() + HStack { + Text("\(page + 1) of 3").foregroundStyle(Color.swMuted).accessibilityLabel("Introduction, page \(page + 1) of 3") + Spacer() + Button("Skip introduction", action: finish).frame(minHeight: 48) + } + VStack(alignment: .leading, spacing: 24) { + if geometry.size.height >= 650 && !typeSize.isAccessibilitySize { + HStack(spacing: 16) { + ForEach(symbols[page], id: \.self) { symbol in + Image(systemName: symbol).font(.system(size: 32)).frame(maxWidth: .infinity, minHeight: 100) + .background(Color.swPanel, in: RoundedRectangle(cornerRadius: 16)) + .overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.swLine)) + } + }.padding(20).background(Color.swSuccessSoft, in: RoundedRectangle(cornerRadius: 24)).accessibilityHidden(true) + } + Text(headings[page]).font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text(details[page]).font(.title3).foregroundStyle(Color.swMuted) + }.id(page).transition(.opacity) + Spacer(minLength: 16) + Button(page == 2 ? "Get started" : "Next") { + if page == 2 { finish() } + else { withAnimation(reduceMotion ? nil : MobileMotion.expandAnimation) { page += 1 } } + }.buttonStyle(SWPrimary()).accessibilityIdentifier("intro-next") + Button("Back") { withAnimation(reduceMotion ? nil : MobileMotion.expandAnimation) { page -= 1 } } + .frame(minHeight: 48).opacity(page == 0 ? 0 : 1) + .disabled(page == 0).accessibilityHidden(page == 0) + }.padding(24).frame(maxWidth: 600).frame(minHeight: geometry.size.height, alignment: .topLeading) + .frame(maxWidth: .infinity) + }.background(Color.swBg) + } + } +} diff --git a/SpaceWorks/PasswordEntry.swift b/SpaceWorks/PasswordEntry.swift new file mode 100644 index 0000000..f8dda80 --- /dev/null +++ b/SpaceWorks/PasswordEntry.swift @@ -0,0 +1,81 @@ +import SwiftUI +import UIKit + +/// One UIKit text input keeps autofill, selection and keyboard identity while visibility changes. +struct PasswordEntry: UIViewRepresentable { + @Binding var text: String + var visible = false + var newPassword = false + var identifier: String + var label = "Password" + var focus: Binding? + var onSubmit: () -> Void = {} + + func makeUIView(context: Context) -> UITextField { + let field = UITextField() + field.delegate = context.coordinator + field.addTarget(context.coordinator, action: #selector(Coordinator.changed), for: .editingChanged) + field.autocorrectionType = .no + field.autocapitalizationType = .none + field.adjustsFontForContentSizeCategory = true + field.font = .preferredFont(forTextStyle: .body) + field.textColor = UIColor(named: "ink") + field.tintColor = UIColor(named: "focus") + field.returnKeyType = .done + field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return field + } + func updateUIView(_ field: UITextField, context: Context) { + context.coordinator.parent = self + field.accessibilityIdentifier = identifier + field.accessibilityLabel = label + field.textContentType = newPassword ? .newPassword : .password + if field.text != text { field.text = text } + if field.isSecureTextEntry == visible { + let selection = field.selectedTextRange + field.isSecureTextEntry = !visible + // UIKit sometimes resets the caret when switching secure entry; restore it immediately. + field.selectedTextRange = selection + } + if focus?.wrappedValue == true, !field.isFirstResponder { + DispatchQueue.main.async { [weak field] in + guard context.coordinator.parent.focus?.wrappedValue == true else { return } + field?.becomeFirstResponder() + } + } + } + func makeCoordinator() -> Coordinator { Coordinator(self) } + final class Coordinator: NSObject, UITextFieldDelegate { + var parent: PasswordEntry + init(_ parent: PasswordEntry) { self.parent = parent } + @objc func changed(_ field: UITextField) { parent.text = field.text ?? "" } + func textFieldDidBeginEditing(_ textField: UITextField) { parent.focus?.wrappedValue = true } + func textFieldDidEndEditing(_ textField: UITextField) { parent.focus?.wrappedValue = false } + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder(); parent.onSubmit(); return true + } + } +} + +struct PasswordControl: View { + let title: String + @Binding var text: String + var newPassword = false + var identifier: String + var focus: Binding? + @State private var visible = false + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title).font(.headline) + HStack(spacing: 8) { + PasswordEntry(text: $text, visible: visible, newPassword: newPassword, identifier: identifier, label: title, focus: focus) + .frame(minHeight: 48) + Button { visible.toggle() } label: { + Image(systemName: visible ? "eye.slash" : "eye").frame(width: 48, height: 48) + }.accessibilityLabel(visible ? "Hide password" : "Show password") + .accessibilityValue(visible ? "Visible" : "Hidden") + }.padding(.leading, 12).background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)) + } + } +} diff --git a/SpaceWorks/ProviderButtons.swift b/SpaceWorks/ProviderButtons.swift new file mode 100644 index 0000000..d7d7be7 --- /dev/null +++ b/SpaceWorks/ProviderButtons.swift @@ -0,0 +1,43 @@ +import SwiftUI +import GoogleSignIn +import AuthenticationServices + +/// Official controls with an injected action. A demo action never requests a provider credential. +struct GoogleProviderButton: UIViewRepresentable { + let wide: Bool + let action: () -> Void + func makeUIView(context: Context) -> GIDSignInButton { + let button = GIDSignInButton() + button.colorScheme = .light + button.addTarget(context.coordinator, action: #selector(Coordinator.perform), for: .touchUpInside) + return button + } + func updateUIView(_ view: GIDSignInButton, context: Context) { + context.coordinator.action = action + view.style = wide ? .wide : .standard + view.accessibilityLabel = "Sign in with Google" + } + func makeCoordinator() -> Coordinator { Coordinator(action: action) } + final class Coordinator: NSObject { + var action: () -> Void + init(action: @escaping () -> Void) { self.action = action } + @objc func perform() { action() } + } +} + +struct AppleProviderButton: UIViewRepresentable { + let action: () -> Void + func makeUIView(context: Context) -> ASAuthorizationAppleIDButton { + let button = ASAuthorizationAppleIDButton(type: .continue, style: .whiteOutline) + button.cornerRadius = 10 + button.addTarget(context.coordinator, action: #selector(Coordinator.perform), for: .touchUpInside) + return button + } + func updateUIView(_ view: ASAuthorizationAppleIDButton, context: Context) { context.coordinator.action = action } + func makeCoordinator() -> Coordinator { Coordinator(action: action) } + final class Coordinator: NSObject { + var action: () -> Void + init(action: @escaping () -> Void) { self.action = action } + @objc func perform() { action() } + } +} diff --git a/SpaceWorks/Resources/Fonts/OFL-Unbounded.txt b/SpaceWorks/Resources/Fonts/OFL-Unbounded.txt new file mode 100644 index 0000000..1c7db28 --- /dev/null +++ b/SpaceWorks/Resources/Fonts/OFL-Unbounded.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Unbounded Project Authors (https://github.com/googlefonts/unbounded) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/SpaceWorks/Resources/Fonts/README.md b/SpaceWorks/Resources/Fonts/README.md new file mode 100644 index 0000000..997192e --- /dev/null +++ b/SpaceWorks/Resources/Fonts/README.md @@ -0,0 +1,11 @@ +# Native accent font + +Unbounded is used for one short branded accent phrase; ordinary interface text uses Apple's native system fonts with Dynamic Type. Gilroy is not embedded because its app license has not been established. + +`Unbounded-Variable.ttf` is the unmodified variable font outlines converted from the Design submodule's `fonts/Unbounded-Variable.woff2` container using fontTools 4.67.0 and Brotli. Its SIL Open Font License 1.1 is included alongside it; the font's family/PostScript names, glyphs and weight axis (200–900) are preserved. Conversion changes the web container into Apple's supported native TrueType container without renaming the font. + +- Source SHA-256: `0c8f2e9da9dbcff90d9e2741cf5c59be72e83d76c206cfa3b95f291416752ee4` +- Native SHA-256: `87526b420190c9008a880bbf1780ab8c1563d55abb772a9cc6ed7e7b81306c3d` +- Registered PostScript name: `Unbounded-Regular` + +The original Space Works logo masters are outlined artwork and do not use this font at runtime. diff --git a/SpaceWorks/Resources/Fonts/Unbounded-Variable.ttf b/SpaceWorks/Resources/Fonts/Unbounded-Variable.ttf new file mode 100644 index 0000000000000000000000000000000000000000..ea0ad43da34edff5243d091b9170f3850ca18fa8 GIT binary patch literal 469444 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 z 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 Date = Date.init, + create: @escaping (String, String, String, String) async throws -> Void = { _, _, _, _ in }, + request: @escaping (String) async throws -> Void = { _ in }, + reset: @escaping (String, String, String) async throws -> Void = { _, _, _ in }) -> AccountAccessState { + AccountAccessState(step: step, now: now, create: create, request: request, reset: reset) + } + func testSignupAcknowledgementClearsSecretsAndDoesNotCreateSession() async { + var captured: [String] = [] + let state = form(step: .signUp, create: { captured = [$0, $1, $2, $3] }) + state.name = " മീര "; state.email = " meera@example.test "; state.password = "a password"; state.confirmation = "a password" + await state.signUp() + XCTAssertEqual(captured, ["മീര", "meera@example.test", "a password", ""]) + XCTAssertEqual(state.step, .created); XCTAssertEqual(state.password, ""); XCTAssertEqual(state.confirmation, "") + } + func testResendDeadlineSurvivesEmailEditingAndRespectsLongerServerDeadline() async { + var clock = Date(timeIntervalSince1970: 100) + var requests = 0 + let state = form(now: { clock }, request: { _ in + requests += 1 + if requests == 2 { throw APIError(status: 429, code: "throttled", detail: "Wait.", retryAfter: clock.addingTimeInterval(120)) } + }) + state.email = "meera@example.test"; await state.requestCode() + XCTAssertEqual(state.resendAfter, Date(timeIntervalSince1970: 160)) + state.changeEmail(); state.email = "another@example.test"; await state.requestCode() + XCTAssertEqual(requests, 1) + clock = Date(timeIntervalSince1970: 161); await state.requestCode() + XCTAssertEqual(requests, 2); XCTAssertEqual(state.resendAfter, Date(timeIntervalSince1970: 281)) + XCTAssertEqual(state.submitAfter, state.resendAfter) + } + func testEveryResetRejectionUsesSameCopyAndNeverLeaksServerPolicy() async { + for status in [400, 401, 403, 409, 429, 503] { + let state = form(step: .confirmReset, reset: { _, _, _ in + throw APIError(status: status, code: "secret_server_policy", detail: "This account exists and password policy failed.") + }) + state.email = "meera@example.test"; state.code = "123456"; state.password = "new password"; state.confirmation = state.password + await state.confirmReset() + XCTAssertEqual(state.error, "Invalid or expired verification code."); XCTAssertEqual(state.step, .confirmReset) + } + } + func testResetLocalValidationPrecedesTransportAndSuccessClearsSecrets() async { + var requests = 0 + let state = form(step: .confirmReset, reset: { _, code, _ in requests += 1; XCTAssertEqual(code, "123456") }) + state.email = "meera@example.test"; state.code = "123456"; state.password = "short"; state.confirmation = "short" + await state.confirmReset(); XCTAssertEqual(requests, 0); XCTAssertEqual(state.error, "Use at least 8 characters.") + state.password = "new password"; state.confirmation = "different password" + await state.confirmReset(); XCTAssertEqual(requests, 0); XCTAssertEqual(state.error, "Passwords don't match.") + state.confirmation = state.password; await state.confirmReset() + XCTAssertEqual(requests, 1); XCTAssertEqual(state.step, .resetComplete) + XCTAssertEqual(state.password, ""); XCTAssertEqual(state.code, "") + } + func testKnownRecoveryUnavailableHasDedicatedState() async { + let state = form(request: { _ in throw APIError(status: 503, code: "recovery_unavailable", detail: "Unavailable.") }) + state.email = "meera@example.test"; await state.requestCode() + XCTAssertEqual(state.step, .unavailable) + let transient = form(request: { _ in throw APIError(status: 503, code: "service_unavailable", detail: "Try later.") }) + transient.email = "meera@example.test"; await transient.requestCode() + XCTAssertEqual(transient.step, .requestReset); XCTAssertEqual(transient.error, "Try later.") + } + func testPhonePreviewResendCannotBeBypassedByChangingNumber() { + var clock = Date(timeIntervalSince1970: 100) + let state = DemoProviderState(now: { clock }) + state.phone = "9876543210"; state.sendCode() + XCTAssertEqual(state.step, .code) + state.changeNumber(); state.phone = "9876543211"; state.sendCode() + XCTAssertEqual(state.step, .phone) + clock = clock.addingTimeInterval(61); state.sendCode() + XCTAssertEqual(state.step, .code) + state.code = "wrong"; XCTAssertFalse(state.complete(phone: true)) + state.code = "123456"; XCTAssertTrue(state.complete(phone: true)); XCTAssertEqual(state.code, "") + } + func testProviderPreviewRefusalsNeverCompleteSignIn() { + for outcome in [DemoProviderState.Outcome.unavailable, .rejected, .expired] { + let state = DemoProviderState(); state.outcome = outcome + XCTAssertFalse(state.complete(phone: false)); XCTAssertNotNil(state.error) + } + } + func testClosingAndReopeningRecoverySharesOnlyServerScopedDeadlines() async { + let scope = "https://\(UUID().uuidString).example/password-recovery" + let clock = Date(timeIntervalSince1970: 100) + let first = AccountAccessState(step: .requestReset, now: { clock }, cooldown: AccountAccessCooldown.scoped(scope), + create: { _, _, _, _ in }, request: { _ in }, reset: { _, _, _ in }) + first.email = "meera@example.test"; await first.requestCode(); first.clearSecrets() + var calls = 0 + let reopened = AccountAccessState(step: .requestReset, now: { clock }, cooldown: AccountAccessCooldown.scoped(scope), + create: { _, _, _, _ in }, request: { _ in calls += 1 }, reset: { _, _, _ in }) + reopened.email = "another@example.test"; await reopened.requestCode() + XCTAssertEqual(calls, 0); XCTAssertEqual(reopened.resendAfter, clock.addingTimeInterval(60)) + XCTAssertEqual(reopened.password, ""); XCTAssertEqual(reopened.code, "") + XCTAssertNotEqual(AccountAccessCooldown.scoped(scope + "-other-server").resendAfter, reopened.resendAfter) + } +} diff --git a/SpaceWorksTests/AccountNetworkingTests.swift b/SpaceWorksTests/AccountNetworkingTests.swift new file mode 100644 index 0000000..332b53b --- /dev/null +++ b/SpaceWorksTests/AccountNetworkingTests.swift @@ -0,0 +1,48 @@ +import XCTest +import Foundation +import SpaceWorksAPI + +final class AccountNetworkingTests: XCTestCase { + func testGeneratedSignupRecoveryAndEmailVerificationUseExactNativeContract() async throws { + var paths: [String] = [] + StubProtocol.lock.lock() + StubProtocol.handler = { request in + let path = request.url!.path + paths.append(path) + XCTAssertNil(request.value(forHTTPHeaderField: "Cookie")) + let data = requestBody(request) + let body = try JSONSerialization.jsonObject(with: data.isEmpty ? Data("{}".utf8) : data) as! [String: String] + switch path { + case "/api/v1/auth/member-sign-up": + XCTAssertEqual(body, ["display_name": "Meera", "email": "meera@example.test", "phone": "", "password": "new password", "website": ""]) + XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) + case "/api/v1/auth/forgot-password": + XCTAssertEqual(body, ["email": "meera@example.test"]) + XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) + case "/api/v1/auth/reset-password": + XCTAssertEqual(body, ["email": "meera@example.test", "code": "123456", "new_password": "new password"]) + XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) + case "/api/v1/auth/email-verification/confirm": + XCTAssertEqual(body, ["code": "123456"]) + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer native-access") + case "/api/v1/auth/email-verification/resend": + XCTAssertTrue(body.isEmpty) + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer native-access") + default: XCTFail("Unexpected generated operation") + } + return (200, ["Content-Type": "application/json"], Data(#"{"detail":"Check your email."}"#.utf8)) + } + StubProtocol.lock.unlock() + let config = DeviceSession.configuration(); config.protocolClasses = [StubProtocol.self] + let service = SpaceWorksService(origin: try ServerOrigin("https://spaceworks.example"), session: DeviceSession.make(configuration: config), + initialTokens: TokenPair(access: "native-access", refresh: "native-refresh")) + try await service.createAccount(name: "Meera", email: "meera@example.test", password: "new password", phone: "") + try await service.requestPasswordReset(email: "meera@example.test") + try await service.resetPassword(email: "meera@example.test", code: "123456", new: "new password") + try await service.confirmEmail(code: "123456") + try await service.resendEmailVerification() + XCTAssertEqual(Set(paths).count, 5) + let pair = await service.refresh.current() + XCTAssertEqual(pair, TokenPair(access: "native-access", refresh: "native-refresh"), "Acknowledgements never replace a native session") + } +} diff --git a/SpaceWorksTests/AuthTests.swift b/SpaceWorksTests/AuthTests.swift index 994de54..92d9854 100644 --- a/SpaceWorksTests/AuthTests.swift +++ b/SpaceWorksTests/AuthTests.swift @@ -172,21 +172,23 @@ final class MemoryTokens: TokenStorage, @unchecked Sendable { auth.code = "123456"; await auth.verify() XCTAssertEqual(auth.stage, .signIn); XCTAssertEqual(auth.error, "Your sign-in expired. Try again.") } - func testTemporaryPasswordGateChangesPasswordThenWipesSession() async throws { + func testTemporaryPasswordGateRefetchesAuthorityBeforeOpeningApp() async throws { let storage = MemoryTokens(TokenPair(access: "test-access", refresh: "test-refresh")) + var changed = false let auth = state(storage: storage) { request in - if request.url!.path.hasSuffix("/me") { return (200, DebugAuthFixtures.user.replacingOccurrences(of: "\"must_change_password\":false", with: "\"must_change_password\":true")) } + if request.url!.path.hasSuffix("/me") { return (200, changed ? DebugAuthFixtures.user : DebugAuthFixtures.user.replacingOccurrences(of: "\"must_change_password\":false", with: "\"must_change_password\":true")) } if request.url!.path.hasSuffix("/change-password") { let body = try JSONSerialization.jsonObject(with: requestBody(request)) as! [String: String] XCTAssertEqual(body, ["current_password": "temporary", "new_password": "replacement"]) + changed = true return (200, #"{"detail":"Updated."}"#) } return try self.response(request) } await auth.restore(); XCTAssertEqual(auth.stage, .passwordChange) await auth.changePassword(current: "temporary", new: "replacement", confirmation: "replacement") - XCTAssertEqual(auth.stage, .signIn); XCTAssertNil(try storage.load()) - XCTAssertEqual(auth.error, "Password updated. Sign in again.") + XCTAssertEqual(auth.stage, .signedIn); XCTAssertNotNil(try storage.load()) + XCTAssertNil(auth.error) } func testDemoBlockedStatesAreExplicitAndResettable() { let demo = DemoSession() diff --git a/SpaceWorksTests/NativeBrandTests.swift b/SpaceWorksTests/NativeBrandTests.swift new file mode 100644 index 0000000..9d163fe --- /dev/null +++ b/SpaceWorksTests/NativeBrandTests.swift @@ -0,0 +1,8 @@ +import XCTest +import UIKit + +@MainActor final class NativeBrandTests: XCTestCase { + func testLicensedNativeAccentFontIsRegisteredByApplication() { + XCTAssertNotNil(UIFont(name: "Unbounded-Regular", size: 24)) + } +} diff --git a/SpaceWorksUITests/MainFlowTests.swift b/SpaceWorksUITests/MainFlowTests.swift index 4d7be61..f8f5159 100644 --- a/SpaceWorksUITests/MainFlowTests.swift +++ b/SpaceWorksUITests/MainFlowTests.swift @@ -40,10 +40,12 @@ final class MainFlowTests: XCTestCase { XCTAssertTrue(app.staticTexts["Welcome back, Maker."].waitForExistence(timeout: 10)) attach(app, "Home member portrait") XCUIDevice.shared.orientation = .landscapeLeft + XCTAssertGreaterThan(app.frame.width, app.frame.height) for name in ["Inventory", "Activity", "Inbox", "Profile"] { tab(app, name) } XCTAssertTrue(app.buttons["Sign out"].waitForExistence(timeout: 5)) attach(app, "Profile landscape") app.buttons["Sign out"].tap() + app.buttons["Sign out"].lastMatch.tap() XCTAssertTrue(app.textFields["username"].waitForExistence(timeout: 10)) } func testMFAContinuesToMakerspaceSelection() { @@ -64,6 +66,7 @@ final class MainFlowTests: XCTestCase { app.buttons["appearance"].tap(); connect(app) app.swipeUp(); attach(app, "Sign in largest text dark portrait") XCUIDevice.shared.orientation = .landscapeLeft + XCTAssertGreaterThan(app.frame.width, app.frame.height) app.swipeDown(); XCTAssertTrue(app.textFields["username"].exists) app.swipeUp(); XCTAssertTrue(app.buttons["sign-in"].exists) attach(app, "Sign in largest text dark landscape") @@ -84,7 +87,48 @@ final class MainFlowTests: XCTestCase { let value = app.textFields["server-url"].value as? String ?? "" XCTAssertTrue(value.isEmpty || value == "https://spaceworks.example") } + func testOptionalIntroductionCompletesAndStaysDismissed() { + let app = launch(extra: ["--introduction", "--reset-introduction"]) + XCTAssertTrue(app.staticTexts["Make room."].waitForExistence(timeout: 10)) + attach(app, "Introduction first page") + app.buttons["intro-next"].tap() + XCTAssertTrue(app.staticTexts["Borrow with confidence."].exists) + app.buttons["intro-next"].tap(); app.buttons["intro-next"].tap() + XCTAssertTrue(app.textFields["server-url"].exists) + app.terminate(); app.launchArguments = ["--ui-testing", "--introduction"]; app.launch() + XCTAssertTrue(app.textFields["server-url"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.buttons["Skip introduction"].exists) + } + func testRecoveryUsesGeneratedRequestAndReturnsToNativeSignIn() { + let app = launch(); connect(app) + app.swipeUp(); app.buttons["Forgot password?"].tap() + let email = app.textFields["account-email"] + XCTAssertTrue(email.waitForExistence(timeout: 5)); email.tap(); email.typeText("meera@example.test") + app.swipeUp(); app.buttons["Request recovery code"].tap() + let code = app.textFields["account-code"] + XCTAssertTrue(code.waitForExistence(timeout: 5)); code.tap(); code.typeText("123456") + let password = app.secureTextFields["account-password"] + password.tap(); password.typeText("new password\n") + let confirmation = app.secureTextFields["account-confirm-password"] + app.swipeUp(); confirmation.tap(); confirmation.typeText("new password\n") + app.swipeUp(); attach(app, "Password recovery review") + app.buttons["Update password"].tap() + XCTAssertTrue(app.staticTexts["Password updated"].waitForExistence(timeout: 5)) + app.buttons["Go to sign in"].tap() + XCTAssertTrue(app.textFields["username"].exists) + } + func testPasswordVisibilityPreservesTextAndKeyboardFocus() { + let app = launch(); connect(app) + app.textFields["username"].tap(); app.textFields["username"].typeText("maker\n") + app.secureTextFields["password"].typeText("sample password") + app.buttons["Show password"].tap() + XCTAssertEqual(app.textFields["password"].value as? String, "sample password") + app.textFields["password"].typeText("!") + app.buttons["Hide password"].tap(); app.buttons["Show password"].tap() + XCTAssertEqual(app.textFields["password"].value as? String, "sample password!") + XCTAssertTrue(app.textFields["username"].exists, "Visibility never submits sign-in") + } private func attach(_ app: XCUIApplication, _ name: String) { - let item = XCTAttachment(screenshot: app.screenshot()); item.name = name; item.lifetime = .keepAlways; add(item) + let item = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); item.name = name; item.lifetime = .keepAlways; add(item) } } diff --git a/design b/design index fd28f82..3c61599 160000 --- a/design +++ b/design @@ -1 +1 @@ -Subproject commit fd28f825086a2cac7fa297881f1860b9d332db69 +Subproject commit 3c615991b40204b74b66f34605ae9364837fbc35 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c947b72..33dbb3a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,6 +1,18 @@ # Phase notes -## A1 Sign-in — implementation checkpoint (2026-10-16) +## UX/onboarding — implementation checkpoint (2026-10-16) + +Pin Design `3c615991b40204b74b66f34605ae9364837fbc35`. Add a short optional introduction, installation-scoped skip/completion and Profile replay, static exact stacked/symbol masters at their minimum sizes, native motion generated from the shared JSON, zero custom motion under Reduce Motion, and one licensed Unbounded accent phrase. Body text remains native system type; include the accent font's SIL license and conversion provenance. + +Use one native password text input so visibility retains value, selection, focus and autofill. Give current/new/confirmation fields distinct VoiceOver labels, semantic placeholders, multiline announced errors, 48pt actions and bounded phone/tablet forms. Config failures offer retry without guessed methods. Add sign-out confirmation and account/server-scoped makerspace selection preferences. + +Add generated account signup, forgot/reset password and authenticated email confirm/resend. Signup/reset acknowledgements never create a session. Reset rejects every server failure with the same copy, with independent local password validation. Shared server/task deadlines survive form dismissal, email editing and reopen; resend waits at least60s or longer Retry-After. Temporary-password completion re-fetches account authority before opening tabs. Unverified email alone does not invent a restricted native session; Profile offers actual verification actions. + +Debug provider previews use official Google/Apple controls with injected local actions; no provider credentials/checkout/push/API calls occur. Phone preview has send/code steps, country selection, resend/cooldown, change number, invalid/expired codes and cancellation. Account creation/recovery preview shares the same native form state with isolated closures. + +Parity: Make room. / Borrow with confidence. / One place for your making.; Skip introduction, Next, Get started; Connect to your makerspace / Server address / Connect; Welcome back. / Sign in to your makerspace account.; Create your account / Account created; Reset password / Enter your recovery code / Password updated; Confirm your email; Check your authenticator / Add extra security; all five member destinations. Backend-deferred native providers remain closed in production. Local14 Python checks/lint pass; new native/network/UI tests remain gated on macOS CI before this checkpoint is complete. Landscape evidence uses full-screen capture plus a real landscape frame assertion to avoid cropped app-screenshot orientation artifacts. + +## A1 Sign-in — complete (2026-10-16) Add generated password/attestation, `/auth/me`, configuration, native MFA verify/enroll/confirm and logout operations. Store the whole token pair in a server-scoped, non-synchronizing, device-only @@ -15,6 +27,8 @@ acknowledgement, makerspace selection, profile/sign-out, constrained forms, iPad tabs, portrait/landscape and semantic light/dark styling. CI is the compiler; this phase is not complete until its new auth/network/UI tests pass. Local generation's six tests and source lint pass. +Completion evidence: [`f7f414e` CI38927362309](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/38927362309) passed phone and iPad simulator builds with40 XCTest cases and5 main UI journeys each,10 Python generation/matrix checks, and the unsigned physical-device Release build. The actual Keychain round trip remains enabled and passes. These are contract-fixture/compiler results; real App Attest/provider/APNs/payment and owner-backend verification remain release gates. + Parity: Email or username, Password, Sign in; Verify your sign-in / Set up two-step sign-in; Authenticator or recovery code, Verify, Back to sign in; I saved my codes. Continue; Choose a makerspace; You haven’t joined a makerspace yet.; Profile, Sign out, Change server. diff --git a/docs/IMPLEMENTATION-PLAN.md b/docs/IMPLEMENTATION-PLAN.md index d0ddef3..a27a181 100644 --- a/docs/IMPLEMENTATION-PLAN.md +++ b/docs/IMPLEMENTATION-PLAN.md @@ -8,6 +8,7 @@ next independent phase. The owner merges the final `dev` → `main` PR. | Phase | Deliverable | Gate | |---|---|---| | A1 | Device password/attestation, MFA, atomic Keychain rotation, restore/logout, direct-membership switcher | Native provider configuration, authority and missing social contracts become backend requests | +| UX/onboarding | Optional introduction/replay, shared motion, native password focus/autofill, signup/recovery/email, all provider demo branches | A1 green; generated supported operations and real phone/tablet screenshots | | A2 | Dashboard, inventory/search/detail, requests/loans, bookings/events, card, inbox | Authenticated generated client and validated makerspace | | A3 | Hardware requests, renewal, booking, registration/ticket, profile/preferences | Server-confirmed write results; no local availability or permission calculation | | A4 | APNs registration and validated in-app routing | Push v2 contract/server link files and owner APNs provisioning | @@ -29,3 +30,8 @@ on a simulator. The owner subsequently deferred the app backend and requested complete flow/UI coverage now. Unsupported planned features will have explicit DEBUG-only in-memory demo journeys and production integration seams; they will not invent endpoints, bypass authority or claim server verification. + +The owner's additional UX checkpoint implements the pinned Design `docs/mobile-auth-ux-spec.md` +and `tokens/mobile-motion.json`. Master artwork stays static; sibling content honors Reduce Motion. +Native email verification is an authenticated Profile action. `email_verified=false` alone is not +an invented restricted-session authority; access-only `verification_only` results never enter the app. diff --git a/scripts/generate.py b/scripts/generate.py index 0bc5d01..48ea427 100644 --- a/scripts/generate.py +++ b/scripts/generate.py @@ -5,6 +5,7 @@ """ import argparse import hashlib +import importlib.util import json import pathlib import plistlib @@ -53,6 +54,7 @@ def generate(raster=True): info = {"CFBundleDisplayName": brand["app_name"], "UILaunchScreen": {}, "UIApplicationSceneManifest": {"UIApplicationSupportsMultipleScenes": False}} info["UISupportedInterfaceOrientations"] = ["UIInterfaceOrientationPortrait", "UIInterfaceOrientationLandscapeLeft", "UIInterfaceOrientationLandscapeRight"] info["UISupportedInterfaceOrientations~ipad"] = info["UISupportedInterfaceOrientations"] + ["UIInterfaceOrientationPortraitUpsideDown"] + info["UIAppFonts"] = ["Unbounded-Variable.ttf"] schemes = ["spaceworks"] google_scheme = brand.get("google_reversed_client_id") if google_scheme is not None: @@ -94,6 +96,10 @@ def generate(raster=True): colors.append(entry) write(assets / f"{role}.colorset/Contents.json", json.dumps({"colors": colors, "info": {"author": "xcode", "version": 1}}, indent=2)) write(ROOT / "SpaceWorks/Generated/Theme.swift", "// Generated from semantic tokens, both appearances.\nimport SwiftUI\nextension Color {\n" + "\n".join(declarations) + "\n}\n") + motion_spec = importlib.util.spec_from_file_location("spaceworks_motion", ROOT / "scripts/generate_motion.py") + motion_module = importlib.util.module_from_spec(motion_spec) + motion_spec.loader.exec_module(motion_module) + motion_module.generate_motion(ROOT) wordmark = assets / "Wordmark.imageset" wordmark.mkdir(parents=True, exist_ok=True) for name, master in [("light.png", "lockup-horizontal-one-colour-ink.png"), ("dark.png", "lockup-horizontal-reversed.png")]: @@ -101,6 +107,14 @@ def generate(raster=True): write(wordmark / "Contents.json", json.dumps({"images": [{"idiom": "universal", "filename": "light.png"}, {"idiom": "universal", "filename": "dark.png", "appearances": [{"appearance": "luminosity", "value": "dark"}]}], "info": {"author": "xcode", "version": 1}})) + for asset, master in [("BrandStacked", "lockup-stacked"), ("BrandSymbol", "symbol")]: + target = assets / f"{asset}.imageset" + target.mkdir(parents=True, exist_ok=True) + for name, suffix in [("light.png", ""), ("dark.png", "-reversed")]: + shutil.copyfile(ROOT / "design/logo" / f"{master}{suffix}.png", target / name) + write(target / "Contents.json", json.dumps({"images": [{"idiom": "universal", "filename": "light.png"}, + {"idiom": "universal", "filename": "dark.png", "appearances": [{"appearance": "luminosity", "value": "dark"}]}], + "info": {"author": "xcode", "version": 1}})) icon = assets / "AppIcon.appiconset" icon.mkdir(parents=True, exist_ok=True) if raster: diff --git a/scripts/generate_motion.py b/scripts/generate_motion.py new file mode 100644 index 0000000..5e2dadc --- /dev/null +++ b/scripts/generate_motion.py @@ -0,0 +1,28 @@ +"""Generate native motion only from the pinned shared Design contract.""" +import json +import pathlib + + +def render_motion(document): + durations = document["duration_ms"] + if document["onboarding"]["logo_motion"] != "none" or document["onboarding"]["blocking_delay_ms"] != 0: + raise ValueError("The master stays static and onboarding never blocks interaction") + reduced = document["reduce_motion"] + if reduced["spatial_transitions"] or reduced["decorative_repeats"] or reduced["blocking_delay_ms"] or reduced["opacity_duration_ms"]: + raise ValueError("Reduced motion must remove all custom movement and delays") + if any(not isinstance(value, int) or value < 0 or value > 1000 for value in durations.values()): + raise ValueError("Invalid duration") + curve = document["easing"]["overlay_enter_cubic_bezier"] + if len(curve) != 4 or any(not isinstance(value, (int, float)) or not 0 <= value <= 1 for value in curve): + raise ValueError("Invalid motion curve") + constants = "\n".join(f" static let {name}: Double = {durations[key] / 1000:g}" for name, key in [ + ("color", "color"), ("expand", "expand"), ("overlayEnter", "overlay_enter"), ("overlayExit", "overlay_exit")]) + return ("// Generated from design/tokens/mobile-motion.json. Do not edit.\nimport SwiftUI\nenum MobileMotion {\n" + + constants + "\n static let expandAnimation = Animation.timingCurve(" + + ", ".join(f"{value:g}" for value in curve) + ", duration: expand)\n}\n") + + +def generate_motion(root): + root = pathlib.Path(root) + document = json.loads((root / "design/tokens/mobile-motion.json").read_text()) + (root / "SpaceWorks/Generated/MobileMotion.swift").write_text(render_motion(document), encoding="utf-8") diff --git a/scripts/tests/test_motion.py b/scripts/tests/test_motion.py new file mode 100644 index 0000000..bc3021c --- /dev/null +++ b/scripts/tests/test_motion.py @@ -0,0 +1,39 @@ +import copy +import hashlib +import importlib.util +import json +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location("motion", ROOT / "scripts/generate_motion.py") +motion = importlib.util.module_from_spec(spec) +spec.loader.exec_module(motion) + + +class MotionTests(unittest.TestCase): + def test_generated_native_motion_matches_pinned_contract(self): + document = json.loads((ROOT / "design/tokens/mobile-motion.json").read_text()) + output = motion.render_motion(document) + self.assertEqual(output, (ROOT / "SpaceWorks/Generated/MobileMotion.swift").read_text()) + self.assertIn(f'static let expand: Double = {document["duration_ms"]["expand"] / 1000:g}', output) + + def test_motion_rejects_logo_animation_and_reduced_motion_delays(self): + original = json.loads((ROOT / "design/tokens/mobile-motion.json").read_text()) + for section, key, invalid in [("onboarding", "logo_motion", "scale"), ("onboarding", "blocking_delay_ms", 100), + ("reduce_motion", "opacity_duration_ms", 120), ("reduce_motion", "spatial_transitions", True)]: + document = copy.deepcopy(original) + document[section][key] = invalid + with self.assertRaises(ValueError): + motion.render_motion(document) + + def test_native_logo_assets_are_exact_unmodified_masters_in_both_appearances(self): + for asset, master in [("BrandStacked", "lockup-stacked"), ("BrandSymbol", "symbol")]: + for filename, suffix in [("light.png", ""), ("dark.png", "-reversed")]: + self.assertEqual((ROOT / f"SpaceWorks/Assets.xcassets/{asset}.imageset/{filename}").read_bytes(), + (ROOT / f"design/logo/{master}{suffix}.png").read_bytes()) + + def test_native_accent_font_has_recorded_license_and_provenance(self): + font = ROOT / "SpaceWorks/Resources/Fonts/Unbounded-Variable.ttf" + self.assertEqual(hashlib.sha256(font.read_bytes()).hexdigest(), "87526b420190c9008a880bbf1780ab8c1563d55abb772a9cc6ed7e7b81306c3d") + self.assertEqual((font.parent / "OFL-Unbounded.txt").read_bytes(), (ROOT / "design/fonts/LICENSES/OFL-Unbounded.txt").read_bytes()) From 74514853b5c6333bf09aa347bc459710bcc235a5 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:55:22 +0530 Subject: [PATCH 09/98] fix(ux): disambiguate native provider button callbacks Co-Authored-By: Codex --- SpaceWorks/ProviderButtons.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/SpaceWorks/ProviderButtons.swift b/SpaceWorks/ProviderButtons.swift index d7d7be7..2e22c3c 100644 --- a/SpaceWorks/ProviderButtons.swift +++ b/SpaceWorks/ProviderButtons.swift @@ -9,7 +9,7 @@ struct GoogleProviderButton: UIViewRepresentable { func makeUIView(context: Context) -> GIDSignInButton { let button = GIDSignInButton() button.colorScheme = .light - button.addTarget(context.coordinator, action: #selector(Coordinator.perform), for: .touchUpInside) + button.addTarget(context.coordinator, action: #selector(Coordinator.buttonPressed), for: .touchUpInside) return button } func updateUIView(_ view: GIDSignInButton, context: Context) { @@ -21,7 +21,7 @@ struct GoogleProviderButton: UIViewRepresentable { final class Coordinator: NSObject { var action: () -> Void init(action: @escaping () -> Void) { self.action = action } - @objc func perform() { action() } + @objc func buttonPressed() { action() } } } @@ -30,7 +30,7 @@ struct AppleProviderButton: UIViewRepresentable { func makeUIView(context: Context) -> ASAuthorizationAppleIDButton { let button = ASAuthorizationAppleIDButton(type: .continue, style: .whiteOutline) button.cornerRadius = 10 - button.addTarget(context.coordinator, action: #selector(Coordinator.perform), for: .touchUpInside) + button.addTarget(context.coordinator, action: #selector(Coordinator.buttonPressed), for: .touchUpInside) return button } func updateUIView(_ view: ASAuthorizationAppleIDButton, context: Context) { context.coordinator.action = action } @@ -38,6 +38,6 @@ struct AppleProviderButton: UIViewRepresentable { final class Coordinator: NSObject { var action: () -> Void init(action: @escaping () -> Void) { self.action = action } - @objc func perform() { action() } + @objc func buttonPressed() { action() } } } From 4b5dbff521804f0e0126e9899aeeb32b22f25bc2 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:05:03 +0530 Subject: [PATCH 10/98] fix(ux): select the visible sign-out confirmation control Co-Authored-By: Codex --- SpaceWorksUITests/MainFlowTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/SpaceWorksUITests/MainFlowTests.swift b/SpaceWorksUITests/MainFlowTests.swift index f8f5159..7f4ff46 100644 --- a/SpaceWorksUITests/MainFlowTests.swift +++ b/SpaceWorksUITests/MainFlowTests.swift @@ -45,7 +45,8 @@ final class MainFlowTests: XCTestCase { XCTAssertTrue(app.buttons["Sign out"].waitForExistence(timeout: 5)) attach(app, "Profile landscape") app.buttons["Sign out"].tap() - app.buttons["Sign out"].lastMatch.tap() + let confirmation = app.buttons.matching(identifier: "Sign out").allElementsBoundByIndex.first { $0.isHittable } + XCTAssertNotNil(confirmation); confirmation?.tap() XCTAssertTrue(app.textFields["username"].waitForExistence(timeout: 10)) } func testMFAContinuesToMakerspaceSelection() { From 32a2be42dba2c377aa4cac19481a702b145cbfa5 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:33:01 +0530 Subject: [PATCH 11/98] feat(a2): build native member dashboard inventory and inbox flows Add generated read operations, isolated sample DTOs, adaptive phone/tablet views, scoped request guards and card invalidation tests. Include bounded native transfer/photo/QR support for later phases and correct the rendered tablet password height. Co-Authored-By: Codex --- Fixtures/Member/activity.json | 62 ++++ Fixtures/Member/bookings.json | 17 + Fixtures/Member/card.json | 18 + Fixtures/Member/categories.json | 42 +++ Fixtures/Member/events.json | 31 ++ Fixtures/Member/inbox.json | 27 ++ Fixtures/Member/inventory.json | 172 +++++++++ Fixtures/Member/spaces.json | 15 + Fixtures/README.md | 5 + .../SpaceWorksAPI/MemberOperations.swift | 44 +++ .../Sources/SpaceWorksAPI/Networking.swift | 15 +- .../openapi-generator-config.yaml | 13 + SpaceWorks/AccountAccessState.swift | 4 +- SpaceWorks/AccountAccessView.swift | 9 + SpaceWorks/AuthState.swift | 9 +- SpaceWorks/AuthView.swift | 3 +- SpaceWorks/DemoMemberRepository.swift | 71 ++++ SpaceWorks/DemoMode.swift | 47 ++- SpaceWorks/LiveMemberRepository.swift | 43 +++ SpaceWorks/MemberState.swift | 200 ++++++++++ SpaceWorks/MemberViews.swift | 348 ++++++++++++++++++ SpaceWorks/NativeAppearance.swift | 4 + SpaceWorks/PasswordEntry.swift | 8 + SpaceWorks/Platform/MetadataCamera.swift | 110 ++++++ .../Platform/NativeDocumentPreview.swift | 39 ++ .../Platform/NativeEvidenceCamera.swift | 105 ++++++ SpaceWorks/Platform/NativeFileTransfer.swift | 263 +++++++++++++ SpaceWorks/Platform/NativeScanner.swift | 22 +- SpaceWorks/RootView.swift | 55 ++- SpaceWorks/SpaceWorksApp.swift | 48 +++ SpaceWorksTests/AppStateTests.swift | 1 + SpaceWorksTests/MemberNetworkingTests.swift | 92 +++++ SpaceWorksTests/MemberTests.swift | 111 ++++++ SpaceWorksTests/NativeFileTransferTests.swift | 157 ++++++++ .../NativePhotoPreparationTests.swift | 70 ++++ SpaceWorksUITests/MainFlowTests.swift | 1 + SpaceWorksUITests/MemberFlowTests.swift | 76 ++++ docs/CHANGELOG.md | 41 ++- scripts/generate.py | 5 + scripts/generate_member_fixtures.py | 14 + 40 files changed, 2379 insertions(+), 38 deletions(-) create mode 100644 Fixtures/Member/activity.json create mode 100644 Fixtures/Member/bookings.json create mode 100644 Fixtures/Member/card.json create mode 100644 Fixtures/Member/categories.json create mode 100644 Fixtures/Member/events.json create mode 100644 Fixtures/Member/inbox.json create mode 100644 Fixtures/Member/inventory.json create mode 100644 Fixtures/Member/spaces.json create mode 100644 Fixtures/README.md create mode 100644 Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/MemberOperations.swift create mode 100644 SpaceWorks/DemoMemberRepository.swift create mode 100644 SpaceWorks/LiveMemberRepository.swift create mode 100644 SpaceWorks/MemberState.swift create mode 100644 SpaceWorks/MemberViews.swift create mode 100644 SpaceWorks/Platform/MetadataCamera.swift create mode 100644 SpaceWorks/Platform/NativeDocumentPreview.swift create mode 100644 SpaceWorks/Platform/NativeEvidenceCamera.swift create mode 100644 SpaceWorks/Platform/NativeFileTransfer.swift create mode 100644 SpaceWorksTests/MemberNetworkingTests.swift create mode 100644 SpaceWorksTests/MemberTests.swift create mode 100644 SpaceWorksTests/NativeFileTransferTests.swift create mode 100644 SpaceWorksTests/NativePhotoPreparationTests.swift create mode 100644 SpaceWorksUITests/MemberFlowTests.swift create mode 100644 scripts/generate_member_fixtures.py diff --git a/Fixtures/Member/activity.json b/Fixtures/Member/activity.json new file mode 100644 index 0000000..5b10ee4 --- /dev/null +++ b/Fixtures/Member/activity.json @@ -0,0 +1,62 @@ +{ + "active_hardware_loans": [ + { + "request_id": 1042, + "renewal_count": 0, + "can_renew": true, + "renewal_refusal": null, + "label": "Digital multimeter", + "checked_out_at": "2026-10-14T09:00:00Z", + "due_at": "2026-10-20T17:00:00Z", + "overdue": false + } + ], + "recent_presence_sessions": [], + "currently_checked_in": false, + "accountability": { + "membership_active": true, + "waiver_acceptance_required": false, + "restriction_code": null + }, + "membership_dues": { + "dues_amount": "250.00", + "outstanding_by_currency": { + "INR": "250.00" + } + }, + "notices": [ + { + "level": "info", + "event": "request_accepted", + "title": "Your kit is ready for handover.", + "body": "Visit the front desk and show your request." + } + ], + "request_history": [ + { + "status": "pending_approval", + "created_at": "2026-10-16T09:00:00Z", + "item_count": 2, + "returned_quantity": 0, + "damaged_quantity": 0, + "missing_quantity": 0 + } + ], + "event_registrations": [ + { + "registration_id": 51, + "checkin_token": null, + "event_title": "Make your first circuit", + "starts_at": "2026-10-20T09:00:00Z", + "ends_at": "2026-10-20T12:00:00Z", + "status": "registered", + "cancellation_cutoff_at": "2026-10-19T09:00:00Z", + "can_cancel": true, + "waitlist_position": null, + "feedback_available": false, + "feedback_path": null, + "certificate": null + } + ], + "loan_history": [] +} diff --git a/Fixtures/Member/bookings.json b/Fixtures/Member/bookings.json new file mode 100644 index 0000000..b17bca8 --- /dev/null +++ b/Fixtures/Member/bookings.json @@ -0,0 +1,17 @@ +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "public_token": "00000000-0000-0000-0000-000000000011", + "space_name": "Electronics workbench", + "timezone": "Asia/Kolkata", + "starts_at": "2026-10-20T09:00:00Z", + "ends_at": "2026-10-20T11:00:00Z", + "status": "confirmed", + "cancellation_cutoff_at": "2026-10-19T09:00:00Z", + "can_cancel": true + } + ] +} diff --git a/Fixtures/Member/card.json b/Fixtures/Member/card.json new file mode 100644 index 0000000..9d9c92a --- /dev/null +++ b/Fixtures/Member/card.json @@ -0,0 +1,18 @@ +{ + "id": 41, + "card_number": 2041, + "printed_name": "Meera Nair", + "membership_id": 7, + "is_active": true, + "photo_set": false, + "photo_consent_at": null, + "qr_active": true, + "print_count": 1, + "last_printed_at": null, + "issued_at": "2026-10-01T09:00:00Z", + "revoked_at": null, + "revoked_reason": "", + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-01T09:00:00Z", + "qr_image": null +} diff --git a/Fixtures/Member/categories.json b/Fixtures/Member/categories.json new file mode 100644 index 0000000..66c15f8 --- /dev/null +++ b/Fixtures/Member/categories.json @@ -0,0 +1,42 @@ +[ + { + "id": 1, + "name": "Electronics", + "slug": "electronics", + "display_order": 1, + "icon": "box", + "product_count": 5 + }, + { + "id": 2, + "name": "Hand tools", + "slug": "hand-tools", + "display_order": 2, + "icon": "box", + "product_count": 4 + }, + { + "id": 3, + "name": "Safety", + "slug": "safety", + "display_order": 3, + "icon": "box", + "product_count": 1 + }, + { + "id": 4, + "name": "Measuring", + "slug": "measuring", + "display_order": 4, + "icon": "box", + "product_count": 1 + }, + { + "id": 5, + "name": "Fabrication", + "slug": "fabrication", + "display_order": 5, + "icon": "box", + "product_count": 1 + } +] diff --git a/Fixtures/Member/events.json b/Fixtures/Member/events.json new file mode 100644 index 0000000..0f92734 --- /dev/null +++ b/Fixtures/Member/events.json @@ -0,0 +1,31 @@ +[ + { + "registration_audience": "members", + "category": null, + "tags": [ + "Workshop" + ], + "ticket_types": [], + "promo_codes_enabled": false, + "ticket_selection_required": false, + "public_token": "00000000-0000-0000-0000-000000000021", + "title": "Make your first circuit", + "description": "A friendly introduction to circuits, sensors and making things together. All materials included.", + "starts_at": "2026-10-20T09:00:00Z", + "ends_at": "2026-10-20T12:00:00Z", + "location": "Electronics studio", + "location_kind": "indoor", + "custom_form": null, + "capacity": 12, + "payment_amount": "0.00", + "currency": "INR", + "availability": "Available", + "registration_requires_approval": false, + "effective_registration_cutoff_at": null, + "registration_open": true, + "image_url": null, + "status": "published", + "organizers": [], + "series": null + } +] diff --git a/Fixtures/Member/inbox.json b/Fixtures/Member/inbox.json new file mode 100644 index 0000000..f645b24 --- /dev/null +++ b/Fixtures/Member/inbox.json @@ -0,0 +1,27 @@ +{ + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "feature": "hardware_requests", + "event": "request_accepted", + "title": "Your kit is ready", + "summary": "Collect your kit at the front desk.", + "link": "/activity", + "read_at": null, + "created_at": "2026-10-16T09:00:00Z" + }, + { + "id": 2, + "feature": "events", + "event": "registration_confirmed", + "title": "You\u2019re on the guest list", + "summary": "Make your first circuit starts on 20 October.", + "link": "/activity/events", + "read_at": "2026-10-16T08:00:00Z", + "created_at": "2026-10-15T09:00:00Z" + } + ] +} diff --git a/Fixtures/Member/inventory.json b/Fixtures/Member/inventory.json new file mode 100644 index 0000000..76b4605 --- /dev/null +++ b/Fixtures/Member/inventory.json @@ -0,0 +1,172 @@ +{ + "count": 12, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "name": "Digital multimeter", + "description": "Ready for your next workshop project. Ask staff if you need a quick introduction.", + "category_id": 1, + "category_name": "Electronics", + "category_slug": "electronics", + "borrowing_terms": "Bring it back in its case, clean and complete. Your makerspace confirms the loan and due date.", + "availability": { + "mode": "status_only", + "label": "Available" + }, + "image_url": null + }, + { + "id": 2, + "name": "Arduino starter kit", + "description": "Ready for your next workshop project. Ask staff if you need a quick introduction.", + "category_id": 1, + "category_name": "Electronics", + "category_slug": "electronics", + "borrowing_terms": "Bring it back in its case, clean and complete. Your makerspace confirms the loan and due date.", + "availability": { + "mode": "status_only", + "label": "Limited" + }, + "image_url": null + }, + { + "id": 3, + "name": "Precision screwdriver set", + "description": "Ready for your next workshop project. Ask staff if you need a quick introduction.", + "category_id": 1, + "category_name": "Hand tools", + "category_slug": "hand-tools", + "borrowing_terms": "Bring it back in its case, clean and complete. Your makerspace confirms the loan and due date.", + "availability": null, + "image_url": null + }, + { + "id": 4, + "name": "Cordless drill", + "description": "Ready for your next workshop project. Ask staff if you need a quick introduction.", + "category_id": 1, + "category_name": "Hand tools", + "category_slug": "hand-tools", + "borrowing_terms": "Bring it back in its case, clean and complete. Your makerspace confirms the loan and due date.", + "availability": { + "mode": "status_only", + "label": "Available" + }, + "image_url": null + }, + { + "id": 5, + "name": "Soldering station", + "description": "Ready for your next workshop project. Ask staff if you need a quick introduction.", + "category_id": 1, + "category_name": "Electronics", + "category_slug": "electronics", + "borrowing_terms": "Bring it back in its case, clean and complete. Your makerspace confirms the loan and due date.", + "availability": { + "mode": "status_only", + "label": "Available" + }, + "image_url": null + }, + { + "id": 6, + "name": "Safety goggles", + "description": "Ready for your next workshop project. Ask staff if you need a quick introduction.", + "category_id": 1, + "category_name": "Safety", + "category_slug": "safety", + "borrowing_terms": "Bring it back in its case, clean and complete. Your makerspace confirms the loan and due date.", + "availability": { + "mode": "status_only", + "label": "Available" + }, + "image_url": null + }, + { + "id": 7, + "name": "Raspberry Pi kit", + "description": "Ready for your next workshop project. Ask staff if you need a quick introduction.", + "category_id": 1, + "category_name": "Electronics", + "category_slug": "electronics", + "borrowing_terms": "Bring it back in its case, clean and complete. Your makerspace confirms the loan and due date.", + "availability": { + "mode": "status_only", + "label": "Available" + }, + "image_url": null + }, + { + "id": 8, + "name": "Woodworking clamps", + "description": "Ready for your next workshop project. Ask staff if you need a quick introduction.", + "category_id": 1, + "category_name": "Hand tools", + "category_slug": "hand-tools", + "borrowing_terms": "Bring it back in its case, clean and complete. Your makerspace confirms the loan and due date.", + "availability": { + "mode": "status_only", + "label": "Available" + }, + "image_url": null + }, + { + "id": 9, + "name": "Digital caliper", + "description": "Ready for your next workshop project. Ask staff if you need a quick introduction.", + "category_id": 1, + "category_name": "Measuring", + "category_slug": "measuring", + "borrowing_terms": "Bring it back in its case, clean and complete. Your makerspace confirms the loan and due date.", + "availability": { + "mode": "status_only", + "label": "Available" + }, + "image_url": null + }, + { + "id": 10, + "name": "Heat gun", + "description": "Ready for your next workshop project. Ask staff if you need a quick introduction.", + "category_id": 1, + "category_name": "Hand tools", + "category_slug": "hand-tools", + "borrowing_terms": "Bring it back in its case, clean and complete. Your makerspace confirms the loan and due date.", + "availability": { + "mode": "status_only", + "label": "Available" + }, + "image_url": null + }, + { + "id": 11, + "name": "Cutting mat", + "description": "Ready for your next workshop project. Ask staff if you need a quick introduction.", + "category_id": 1, + "category_name": "Fabrication", + "category_slug": "fabrication", + "borrowing_terms": "Bring it back in its case, clean and complete. Your makerspace confirms the loan and due date.", + "availability": { + "mode": "status_only", + "label": "Available" + }, + "image_url": null + }, + { + "id": 12, + "name": "Bench power supply", + "description": "Ready for your next workshop project. Ask staff if you need a quick introduction.", + "category_id": 1, + "category_name": "Electronics", + "category_slug": "electronics", + "borrowing_terms": "Bring it back in its case, clean and complete. Your makerspace confirms the loan and due date.", + "availability": { + "mode": "status_only", + "label": "Available" + }, + "image_url": null + } + ] +} diff --git a/Fixtures/Member/spaces.json b/Fixtures/Member/spaces.json new file mode 100644 index 0000000..7a3bfd6 --- /dev/null +++ b/Fixtures/Member/spaces.json @@ -0,0 +1,15 @@ +[ + { + "public_token": "00000000-0000-0000-0000-000000000031", + "name": "Electronics workbench", + "kind": "bench", + "description": "A focused workbench with power, task lighting and room for your project.", + "capacity": 2, + "location": "Ground floor", + "image_url": "", + "approval_mode": "instant", + "custom_form": null, + "show_public_availability": true, + "show_public_booker_names": false + } +] diff --git a/Fixtures/README.md b/Fixtures/README.md new file mode 100644 index 0000000..2fc0976 --- /dev/null +++ b/Fixtures/README.md @@ -0,0 +1,5 @@ +# Synthetic member contract fixtures + +These records are invented sample content decoded through the exact generated API schemas in native tests and the explicitly labelled Debug demo. They are not a live backend response or production cache. The generator places them inside a `#if DEBUG` Swift source file; this directory is outside the app resource target and is not bundled in Release. + +Use the generated models and the source contract's field names/status values. Keep availability, dues, membership-card state and server status authoritative in production. Sample payment or staff results never authorize a live action. diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/MemberOperations.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/MemberOperations.swift new file mode 100644 index 0000000..4be08d3 --- /dev/null +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/MemberOperations.swift @@ -0,0 +1,44 @@ +import Foundation + +extension SpaceWorksService { + public func memberInventory(slug: String, page: Int, search: String, category: String?) async throws -> Components.Schemas.PaginatedPublicProductList { + try await client.api_v1_public_inventory_list(.init(path: .init(makerspace_slug: slug), + query: .init(category: category, page: page, q: search.isEmpty ? nil : search))).ok.body.json + } + public func memberProduct(slug: String, id: Int) async throws -> Components.Schemas.PublicProduct { + try await client.api_v1_public_inventory_retrieve(.init(path: .init(id: id, makerspace_slug: slug))).ok.body.json + } + public func memberCategories(slug: String) async throws -> [Components.Schemas.PublicCategory] { + try await client.api_v1_public_inventory_categories_list(.init(path: .init(makerspace_slug: slug))).ok.body.json + } + public func memberActivity(id: Int) async throws -> Components.Schemas.MemberActivity { + try await client.api_v1_member_makerspaces_activity_retrieve(.init(path: .init(makerspace_id: id))).ok.body.json + } + public func memberBookings(slug: String, page: Int) async throws -> Components.Schemas.MemberBookingList { + try await client.api_v1_public_my_bookings_retrieve(.init(path: .init(makerspace_slug: slug), query: .init(page: page, period: .upcoming))).ok.body.json + } + public func memberEvents(slug: String) async throws -> [Components.Schemas.PublicEvent] { + try await client.api_v1_public_events_list(.init(path: .init(makerspace_slug: slug))).ok.body.json + } + public func memberEvent(slug: String, token: String) async throws -> Components.Schemas.PublicEvent { + try await client.api_v1_public_events_retrieve(.init(path: .init(makerspace_slug: slug, public_token: token))).ok.body.json + } + public func memberSpaces(slug: String) async throws -> [Components.Schemas.PublicBookableSpace] { + try await client.api_v1_public_spaces_list(.init(path: .init(makerspace_slug: slug))).ok.body.json + } + public func memberCard(id: Int) async throws -> Components.Schemas.OwnMemberCard { + try await client.api_v1_member_makerspaces_member_card_retrieve(.init(path: .init(makerspace_id: id))).ok.body.json + } + public func memberInbox(id: Int, page: Int, unread: Bool) async throws -> Components.Schemas.InboxPage { + try await client.api_v1_notifications_inbox_retrieve(.init(path: .init(makerspace_id: id), query: .init(page: page, unread: unread))).ok.body.json + } + public func memberMarkRead(id: Int, message: Int) async throws { + _ = try await client.api_v1_notifications_inbox_read_create(.init(path: .init(id: message, makerspace_id: id))).ok.body.json + } + public func memberMarkAllRead(id: Int) async throws { + _ = try await client.api_v1_notifications_inbox_read_all_create(.init(path: .init(makerspace_id: id))).ok.body.json + } + public func memberUnreadCount(id: Int) async throws -> Components.Schemas.Count { + try await client.api_v1_notifications_inbox_unread_count_retrieve(.init(path: .init(makerspace_id: id))).ok.body.json + } +} diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift index b344a02..8c70948 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift @@ -205,6 +205,10 @@ private struct ErrorMiddleware: ClientMiddleware { } } +private enum DeviceRequestScope { + @TaskLocal static var makerspace: Int? +} + public struct SessionMiddleware: ClientMiddleware { public let refresh: RefreshCoordinator public let makerspace: MakerspaceContext @@ -215,10 +219,13 @@ public struct SessionMiddleware: ClientMiddleware { for name in ["Cookie", "Origin", "Referer"] { request.headerFields[HTTPField.Name(name)!] = nil } let pair = await refresh.current() request.headerFields[.authorization] = pair.map { "Bearer \($0.access)" } - request.headerFields[HTTPField.Name("X-Makerspace-Id")!] = await makerspace.current().map(String.init) + let selected = await makerspace.current() + if let expected = DeviceRequestScope.makerspace, expected != selected { throw CancellationError() } + request.headerFields[HTTPField.Name("X-Makerspace-Id")!] = (DeviceRequestScope.makerspace ?? selected).map(String.init) let result = try await next(request, body, baseURL) if result.0.status.code == 401, let pair { let updated = try await refresh.recover(rejectedAccess: pair.access) + if let expected = DeviceRequestScope.makerspace, await makerspace.current() != expected { throw CancellationError() } request.headerFields[.authorization] = "Bearer \(updated.access)" return try await checked(next(request, body, baseURL)) } @@ -248,7 +255,7 @@ public enum DeviceSession { } public final class SpaceWorksService: Sendable { - private let client: Client + let client: Client private let publicClient: Client public let refresh: RefreshCoordinator public let makerspace: MakerspaceContext @@ -273,6 +280,10 @@ public final class SpaceWorksService: Sendable { let result = try await client.api_v1_health_retrieve() _ = try result.ok.body.json } + /// A task's makerspace header stays bound to its original repository across actor suspension. + public func withMakerspace(_ id: Int, operation: @Sendable () async throws -> T) async throws -> T { + try await DeviceRequestScope.$makerspace.withValue(id, operation: operation) + } public func configuration() async throws -> Components.Schemas.PublicConfig { try await publicClient.api_v1_config_retrieve().ok.body.json diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml index f3a3178..e76ca0e 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml @@ -20,3 +20,16 @@ filter: - api_v1_auth_reset_password_create - api_v1_auth_email_verification_confirm_create - api_v1_auth_email_verification_resend_create + - api_v1_member_makerspaces_activity_retrieve + - api_v1_member_makerspaces_member_card_retrieve + - api_v1_notifications_inbox_retrieve + - api_v1_notifications_inbox_read_create + - api_v1_notifications_inbox_read_all_create + - api_v1_notifications_inbox_unread_count_retrieve + - api_v1_public_events_list + - api_v1_public_events_retrieve + - api_v1_public_inventory_list + - api_v1_public_inventory_retrieve + - api_v1_public_inventory_categories_list + - api_v1_public_my_bookings_retrieve + - api_v1_public_spaces_list diff --git a/SpaceWorks/AccountAccessState.swift b/SpaceWorks/AccountAccessState.swift index a7afe19..dce0e43 100644 --- a/SpaceWorks/AccountAccessState.swift +++ b/SpaceWorks/AccountAccessState.swift @@ -17,6 +17,7 @@ final class AccountAccessCooldown { @MainActor @Observable final class AccountAccessState: Identifiable { let id = UUID() + let isDemo: Bool enum Step: Equatable { case signUp, created, requestReset, confirmReset, resetComplete, unavailable, emailVerification } var step: Step var name = "" @@ -44,7 +45,7 @@ final class AccountAccessState: Identifiable { private let resendVerification: () async throws -> Void private let now: () -> Date - init(step: Step, now: @escaping () -> Date = Date.init, cooldown: AccountAccessCooldown? = nil, + init(step: Step, now: @escaping () -> Date = Date.init, cooldown: AccountAccessCooldown? = nil, isDemo: Bool = false, create: @escaping (String, String, String, String) async throws -> Void, request: @escaping (String) async throws -> Void, reset: @escaping (String, String, String) async throws -> Void, @@ -53,6 +54,7 @@ final class AccountAccessState: Identifiable { self.step = step; self.now = now; self.create = create; self.request = request self.reset = reset; self.verify = verify; self.resendVerification = resendVerification self.cooldown = cooldown ?? AccountAccessCooldown() + self.isDemo = isDemo } var canResend: Bool { !busy && now() >= resendAfter } func signUp() async { diff --git a/SpaceWorks/AccountAccessView.swift b/SpaceWorks/AccountAccessView.swift index 211a97e..c45baea 100644 --- a/SpaceWorks/AccountAccessView.swift +++ b/SpaceWorks/AccountAccessView.swift @@ -8,6 +8,7 @@ struct AccountAccessView: View { ScrollView { VStack(alignment: .leading, spacing: 24) { BrandWordmark() + if form.isDemo { Label("Demo mode · no account changes are made", systemImage: "info.circle").foregroundStyle(Color.swMuted) } SWCard { Text(heading).font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) Text(explanation).foregroundStyle(Color.swMuted) @@ -68,6 +69,14 @@ struct AccountAccessView: View { } } private var explanation: String { + if form.isDemo { + switch form.step { + case .requestReset, .confirmReset: return "No message is sent in this demo. Use the sample recovery code 123456." + case .created: return "Simulated account creation. No account was created on a server." + case .resetComplete: return "Simulated password update. No real account was changed." + default: break + } + } switch form.step { case .signUp: return "Use the email you want to use with your makerspace." case .created: return "Sign in to continue. You may need to confirm your email." diff --git a/SpaceWorks/AuthState.swift b/SpaceWorks/AuthState.swift index 137a6ae..73a2560 100644 --- a/SpaceWorks/AuthState.swift +++ b/SpaceWorks/AuthState.swift @@ -149,7 +149,10 @@ final class AuthState { try await service.makerspace.select(id, activeMemberships: Set(memberships.map(\.id))) selectedID = id settings.set(id, forKey: selectionKey) - } catch { fail(error) } + } catch { + try? await service.makerspace.select(nil, activeMemberships: Set(memberships.map(\.id))) + selectedID = nil; fail(error) + } } func foreground() async { guard stage == .signedIn, !busy else { return } @@ -161,6 +164,10 @@ final class AuthState { if isUnauthorized(error) || hasNoCredentials { try? await service.refresh.install(nil); await clear() } } } + func invalidateSession(message: String) async { + try? await service.refresh.install(nil) + await clear(); error = message + } func signOut() async { guard !busy else { return } busy = true; error = nil diff --git a/SpaceWorks/AuthView.swift b/SpaceWorks/AuthView.swift index 6ba8a9d..2f9b7ef 100644 --- a/SpaceWorks/AuthView.swift +++ b/SpaceWorks/AuthView.swift @@ -217,10 +217,11 @@ struct MFAView: View { } struct QRCodeView: View { let value: String + var label = "Authenticator setup QR code" var body: some View { if let image = image { Image(uiImage: image).interpolation(.none).resizable().scaledToFit().frame(maxWidth: 240, maxHeight: 240) - .accessibilityLabel("Authenticator setup QR code") + .padding(12).background(Color.white).accessibilityLabel(label) } } private var image: UIImage? { diff --git a/SpaceWorks/DemoMemberRepository.swift b/SpaceWorks/DemoMemberRepository.swift new file mode 100644 index 0000000..30d0f07 --- /dev/null +++ b/SpaceWorks/DemoMemberRepository.swift @@ -0,0 +1,71 @@ +#if DEBUG +import Foundation +import SpaceWorksAPI + +actor DemoMemberRepository: MemberRepository { + nonisolated let isDemo = true + enum Scenario: String, CaseIterable, Sendable { case populated = "Populated", empty = "Empty", offline = "Error", revokedCard = "Revoked card" } + let scenario: Scenario + let name: String + private var readIDs = Set() + init(scenario: Scenario = .populated, name: String = "Meera Nair") { self.scenario = scenario; self.name = name } + private func decode(_ type: T.Type, _ json: String) throws -> T { + if scenario == .offline { throw URLError(.notConnectedToInternet) } + let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(type, from: Data(json.utf8)) + } + func inventory(page: Int, search: String, category: String?) async throws -> Components.Schemas.PaginatedPublicProductList { + var object = try JSONSerialization.jsonObject(with: Data(DebugMemberFixtures.inventory.utf8)) as! [String: Any] + let all = (object["results"] as! [[String: Any]]).filter { item in + (search.isEmpty || (item["name"] as! String).localizedCaseInsensitiveContains(search)) && + (category == nil || item["category_slug"] as? String == category) + } + let rows = scenario == .empty ? [] : Array(all.dropFirst(max(0, page - 1) * 6).prefix(6)) + object["results"] = rows; object["count"] = scenario == .empty ? 0 : all.count + if scenario != .empty && page * 6 < all.count { object["next"] = "/api/v1/public/fab-lab-kochi/inventory/?page=\(page + 1)" } + else { object["next"] = NSNull() } + return try decode(Components.Schemas.PaginatedPublicProductList.self, String(decoding: JSONSerialization.data(withJSONObject: object), as: UTF8.self)) + } + func product(_ id: Int) async throws -> Components.Schemas.PublicProduct { + let inventory = try decode(Components.Schemas.PaginatedPublicProductList.self, DebugMemberFixtures.inventory) + guard let product = inventory.results.first(where: { $0.id == id }) else { throw APIError(status: 404, code: "not_found", detail: "This item is no longer available.") } + return product + } + func categories() async throws -> [Components.Schemas.PublicCategory] { try decode([Components.Schemas.PublicCategory].self, DebugMemberFixtures.categories) } + func activity() async throws -> Components.Schemas.MemberActivity { + var object = try JSONSerialization.jsonObject(with: Data(DebugMemberFixtures.activity.utf8)) as! [String: Any] + if scenario == .empty { + for key in ["active_hardware_loans", "request_history", "loan_history", "event_registrations", "notices"] { object[key] = [] } + object["membership_dues"] = ["dues_amount": "0", "outstanding_by_currency": [String: String]()] as [String: Any] + } + return try decode(Components.Schemas.MemberActivity.self, String(decoding: JSONSerialization.data(withJSONObject: object), as: UTF8.self)) + } + func bookings(page: Int) async throws -> Components.Schemas.MemberBookingList { + try decode(Components.Schemas.MemberBookingList.self, scenario == .empty ? #"{"count":0,"next":null,"previous":null,"results":[]}"# : DebugMemberFixtures.bookings) + } + func events() async throws -> [Components.Schemas.PublicEvent] { try decode([Components.Schemas.PublicEvent].self, scenario == .empty ? "[]" : DebugMemberFixtures.events) } + func spaces() async throws -> [Components.Schemas.PublicBookableSpace] { try decode([Components.Schemas.PublicBookableSpace].self, scenario == .empty ? "[]" : DebugMemberFixtures.spaces) } + func card() async throws -> Components.Schemas.OwnMemberCard { + if scenario == .empty { throw APIError(status: 404, code: "not_issued", detail: "A membership card hasn't been issued yet.") } + var object = try JSONSerialization.jsonObject(with: Data(DebugMemberFixtures.card.utf8)) as! [String: Any] + object["printed_name"] = name + if scenario == .revokedCard { object["is_active"] = false; object["qr_active"] = false } + return try decode(Components.Schemas.OwnMemberCard.self, String(decoding: JSONSerialization.data(withJSONObject: object), as: UTF8.self)) + } + func inbox(page: Int, unread: Bool) async throws -> Components.Schemas.InboxPage { + var object = try JSONSerialization.jsonObject(with: Data(DebugMemberFixtures.inbox.utf8)) as! [String: Any] + var rows = object["results"] as! [[String: Any]] + for index in rows.indices where readIDs.contains(rows[index]["id"] as! Int) { rows[index]["read_at"] = "2026-10-16T10:00:00Z" } + if unread { rows = rows.filter { $0["read_at"] is NSNull } } + if scenario == .empty { rows = [] } + object["results"] = rows; object["count"] = rows.count + return try decode(Components.Schemas.InboxPage.self, String(decoding: JSONSerialization.data(withJSONObject: object), as: UTF8.self)) + } + func markRead(_ id: Int) async throws { if scenario == .offline { throw URLError(.notConnectedToInternet) }; readIDs.insert(id) } + func markAllRead() async throws { + let page = try await inbox(page: 1, unread: false) + readIDs.formUnion(page.results.map(\.id)) + } + func unreadCount() async throws -> Int { try await inbox(page: 1, unread: true).results.count } +} +#endif diff --git a/SpaceWorks/DemoMode.swift b/SpaceWorks/DemoMode.swift index 5e53dfb..aae219d 100644 --- a/SpaceWorks/DemoMode.swift +++ b/SpaceWorks/DemoMode.swift @@ -19,8 +19,17 @@ final class DemoSession { var error: String? var retryAfter: Date? var selectedSpace = 0 + var memberScenario = DemoMemberRepository.Scenario.populated let spaces = ["Fab Lab Kochi", "Maker Space Bengaluru"] var name: String { role == .member ? "Meera Nair" : "Arjun Rao" } + init() { + let arguments = ProcessInfo.processInfo.arguments + if arguments.contains("--ui-testing") { + if arguments.contains("--demo-empty") { memberScenario = .empty } + if arguments.contains("--demo-error") { memberScenario = .offline } + if arguments.contains("--demo-revoked") { memberScenario = .revokedCard } + } + } func signIn() { guard !username.isEmpty, !password.isEmpty else { error = "Enter your email or username and password."; return } guard (retryAfter ?? .distantPast) <= Date() else { return } @@ -58,7 +67,9 @@ struct DemoRoot: View { Spacer() Button("Exit demo") { session.reset(); exit() }.frame(minHeight: 48) }.padding(.horizontal, 20).foregroundStyle(Color.swOnEmphasis).background(Color.swEmphasis) - if session.stage == .shell { DemoShell(session: session) } + if session.stage == .shell { + DemoShell(session: session).id("\(session.role.rawValue).\(session.selectedSpace).\(session.memberScenario.rawValue)") + } else { DemoAuthView(session: session) } }.background(Color.swBg) } @@ -81,6 +92,7 @@ struct DemoAuthView: View { Picker("Demo role", selection: $session.role) { ForEach(DemoSession.Role.allCases, id: \.self) { Text($0.rawValue) } } .pickerStyle(.segmented).accessibilityIdentifier("demo-role") Picker("Sign-in preview", selection: $session.scenario) { ForEach(DemoSession.Scenario.allCases, id: \.self) { Text($0.rawValue) } } + Picker("App data preview", selection: $session.memberScenario) { ForEach(DemoMemberRepository.Scenario.allCases, id: \.self) { Text($0.rawValue) } } Button("Continue as \(session.role.rawValue.lowercased())") { session.stage = .signIn }.buttonStyle(SWPrimary()) case .signIn: Text("Welcome back.").font(.largeTitle.bold()) @@ -152,7 +164,7 @@ struct DemoAuthView: View { .sheet(item: $accountForm) { form in AccountAccessView(form: form) { session.username = form.email; accountForm = nil } } } private func demoAccountForm(_ step: AccountAccessState.Step) -> AccountAccessState { - AccountAccessState(step: step, create: { _, _, _, _ in }, request: { _ in }, reset: { _, code, _ in + AccountAccessState(step: step, isDemo: true, create: { _, _, _, _ in }, request: { _ in }, reset: { _, code, _ in if code != "123456" { throw NSError(domain: "DemoReset", code: 1) } }) } @@ -167,7 +179,7 @@ struct DemoProviderView: View { ScrollView { SWCard { Text("Continue with \(provider.rawValue)").font(.largeTitle.bold()) - Text("Demo only. No provider account is connected and no message is sent.").foregroundStyle(Color.swMuted) + Text("Demo mode. No provider account is connected and no message is sent.").foregroundStyle(Color.swMuted) if provider == .phone { if state.step == .phone { Text("We'll send a verification code.").foregroundStyle(Color.swMuted) @@ -205,9 +217,15 @@ struct DemoProviderView: View { struct DemoShell: View { let session: DemoSession + @State private var member: MemberState @Environment(\.horizontalSizeClass) private var sizeClass @State private var selected = AppTab.home + init(session: DemoSession) { + self.session = session + _member = State(initialValue: MemberState(repository: DemoMemberRepository(scenario: session.memberScenario, name: session.name))) + } var body: some View { + VStack(spacing: 0) { if sizeClass == .regular { NavigationSplitView { List(AppTab.allCases) { tab in @@ -215,33 +233,46 @@ struct DemoShell: View { .listRowBackground(selected == tab ? Color.swSidebarActive : Color.swSidebar) .foregroundStyle(selected == tab ? Color.swOnAccent : Color.swInk) }.scrollContentBackground(.hidden).background(Color.swSidebar).navigationTitle(Brand.appName) - } detail: { NavigationStack { DemoDestination(session: session, tab: selected) } } + } detail: { NavigationStack { DemoDestination(session: session, member: member, tab: selected) { selected = $0 } } } } else { TabView(selection: $selected) { ForEach(AppTab.allCases) { tab in - NavigationStack { DemoDestination(session: session, tab: tab) } + NavigationStack { DemoDestination(session: session, member: member, tab: tab) { selected = $0 } } .tabItem { Label(tab.rawValue, systemImage: tab.symbol) }.tag(tab) + .badge(tab == .inbox ? member.unreadCount ?? 0 : 0) } } } + }.task { await member.loadUnreadCount() }.onDisappear { member.invalidate() } } } struct DemoDestination: View { let session: DemoSession + let member: MemberState let tab: AppTab + let selectTab: (AppTab) -> Void var body: some View { - ScrollView { + Group { + if tab != .profile { MemberContent(state: member, tab: tab, name: session.name, selectTab: selectTab) } + else { ScrollView { VStack(alignment: .leading, spacing: 24) { Text("Welcome back, \(session.name).").font(.largeTitle.bold()) Text(session.spaces[session.selectedSpace]).foregroundStyle(Color.swMuted) SWCard { - if tab == .profile { Text(session.role.rawValue).font(.headline) + Button("Show helpful tips") { member.replayGuidance() }.frame(minHeight: 48) Button("Sign out") { session.reset() }.frame(minHeight: 48) - } else { Text("Nothing here yet.") } } }.padding(24).frame(maxWidth: 960).frame(maxWidth: .infinity) + } } }.background(Color.swBg).navigationTitle(tab.rawValue) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + ForEach(session.spaces.indices, id: \.self) { index in Button(session.spaces[index]) { session.selectedSpace = index } } + } label: { Label(session.spaces[session.selectedSpace], systemImage: "building.2") } + } + } } } private extension View { diff --git a/SpaceWorks/LiveMemberRepository.swift b/SpaceWorks/LiveMemberRepository.swift new file mode 100644 index 0000000..693c71e --- /dev/null +++ b/SpaceWorks/LiveMemberRepository.swift @@ -0,0 +1,43 @@ +import Foundation +import SpaceWorksAPI + +struct LiveMemberRepository: MemberRepository { + let service: SpaceWorksService + let origin: ServerOrigin + let id: Int + let slug: String + var onUnauthorized: @Sendable (String) async -> Void = { _ in } + var onForbidden: @Sendable () async -> Void = {} + private func checked(_ work: @Sendable () async throws -> T) async throws -> T { + do { return try await service.withMakerspace(id, operation: work) } catch { + let empty = await service.refresh.current() == nil + if APIError.status(error) == 401 || empty { await onUnauthorized(APIError.display(error)) } + else if APIError.status(error) == 403 { await onForbidden() } + throw error + } + } + func inventory(page: Int, search: String, category: String?) async throws -> Components.Schemas.PaginatedPublicProductList { + let result = try await checked { try await service.memberInventory(slug: slug, page: page, search: search, category: category) } + _ = try origin.nextPage(result.next) + return result + } + func product(_ id: Int) async throws -> Components.Schemas.PublicProduct { try await checked { try await service.memberProduct(slug: slug, id: id) } } + func categories() async throws -> [Components.Schemas.PublicCategory] { try await checked { try await service.memberCategories(slug: slug) } } + func activity() async throws -> Components.Schemas.MemberActivity { try await checked { try await service.memberActivity(id: id) } } + func bookings(page: Int) async throws -> Components.Schemas.MemberBookingList { + let result = try await checked { try await service.memberBookings(slug: slug, page: page) } + _ = try origin.nextPage(result.next) + return result + } + func events() async throws -> [Components.Schemas.PublicEvent] { try await checked { try await service.memberEvents(slug: slug) } } + func spaces() async throws -> [Components.Schemas.PublicBookableSpace] { try await checked { try await service.memberSpaces(slug: slug) } } + func card() async throws -> Components.Schemas.OwnMemberCard { try await checked { try await service.memberCard(id: id) } } + func inbox(page: Int, unread: Bool) async throws -> Components.Schemas.InboxPage { + let result = try await checked { try await service.memberInbox(id: id, page: page, unread: unread) } + _ = try origin.nextPage(result.next) + return result + } + func markRead(_ message: Int) async throws { try await checked { try await service.memberMarkRead(id: id, message: message) } } + func markAllRead() async throws { try await checked { try await service.memberMarkAllRead(id: id) } } + func unreadCount() async throws -> Int { try await checked { try await service.memberUnreadCount(id: id).count } } +} diff --git a/SpaceWorks/MemberState.swift b/SpaceWorks/MemberState.swift new file mode 100644 index 0000000..f34e5a3 --- /dev/null +++ b/SpaceWorks/MemberState.swift @@ -0,0 +1,200 @@ +import Foundation +import Observation +import SpaceWorksAPI + +/// Generated models are retained intact; this protocol describes screen tasks, not a transport schema. +protocol MemberRepository: Sendable { + var isDemo: Bool { get } + func inventory(page: Int, search: String, category: String?) async throws -> Components.Schemas.PaginatedPublicProductList + func product(_ id: Int) async throws -> Components.Schemas.PublicProduct + func categories() async throws -> [Components.Schemas.PublicCategory] + func activity() async throws -> Components.Schemas.MemberActivity + func bookings(page: Int) async throws -> Components.Schemas.MemberBookingList + func events() async throws -> [Components.Schemas.PublicEvent] + func spaces() async throws -> [Components.Schemas.PublicBookableSpace] + func card() async throws -> Components.Schemas.OwnMemberCard + func inbox(page: Int, unread: Bool) async throws -> Components.Schemas.InboxPage + func markRead(_ id: Int) async throws + func markAllRead() async throws + func unreadCount() async throws -> Int +} +extension MemberRepository { var isDemo: Bool { false } } + +@MainActor @Observable +final class MemberState { + enum Load: Equatable { case idle, loading, ready, failed(String) } + let repository: any MemberRepository + var search = "" + var category: String? + var unreadOnly = false + private(set) var products: [Components.Schemas.PublicProduct] = [] + private(set) var categories: [Components.Schemas.PublicCategory] = [] + private(set) var activity: Components.Schemas.MemberActivity? + private(set) var bookings: [Components.Schemas.MemberBooking] = [] + private(set) var events: [Components.Schemas.PublicEvent] = [] + private(set) var spaces: [Components.Schemas.PublicBookableSpace] = [] + private(set) var card: Components.Schemas.OwnMemberCard? + private(set) var messages: [Components.Schemas.Inbox] = [] + private(set) var inventoryLoad = Load.idle + private(set) var activityLoad = Load.idle + private(set) var bookingsLoad = Load.idle + private(set) var eventsLoad = Load.idle + private(set) var spacesLoad = Load.idle + private(set) var cardLoad = Load.idle + private(set) var inboxLoad = Load.idle + private(set) var inventoryHasMore = false + private(set) var inboxHasMore = false + private(set) var bookingsHasMore = false + private(set) var inboxMutating = false + private(set) var unreadCount: Int? + private(set) var updated: [String: Date] = [:] + private(set) var showGuidance: Bool + private let settings: UserDefaults? + private let guidanceKey: String? + private var nextInventoryPage: Int? + private var nextInboxPage: Int? + private var nextBookingsPage: Int? + private var bookingsGeneration = 0 + private var appliedSearch = "" + private var appliedCategory: String? + private var inventoryGeneration = 0 + private var inboxGeneration = 0 + private var active = true + private var cardGeneration = 0 + + init(repository: any MemberRepository, settings: UserDefaults? = nil, context: String? = nil) { + self.repository = repository; self.settings = settings + self.guidanceKey = context.map { "guidance.member-v1." + $0 } + self.showGuidance = context.map { settings?.object(forKey: "guidance.member-v1." + $0) as? Bool ?? true } ?? true + } + func dismissGuidance() { showGuidance = false; if let guidanceKey { settings?.set(false, forKey: guidanceKey) } } + func replayGuidance() { showGuidance = true; if let guidanceKey { settings?.set(true, forKey: guidanceKey) } } + func invalidate() { + active = false; inventoryGeneration += 1; inboxGeneration += 1; cardGeneration += 1 + products = []; categories = []; activity = nil; bookings = []; events = []; spaces = []; card = nil; messages = []; unreadCount = nil; updated = [:] + } + func loadHome() async { + async let activity: Void = loadActivity() + async let bookings: Void = loadBookings() + async let events: Void = loadEvents() + async let card: Void = loadCard() + _ = await (activity, bookings, events, card) + } + func loadInventory(more: Bool = false) async { + if more && (inventoryLoad == .loading || !inventoryHasMore) { return } + if more && (search.trimmingCharacters(in: .whitespacesAndNewlines) != appliedSearch || category != appliedCategory) { + await loadInventory(); return + } + inventoryGeneration += 1 + let generation = inventoryGeneration + let page = more ? (nextInventoryPage ?? 1) : 1 + let query = search.trimmingCharacters(in: .whitespacesAndNewlines) + let filter = category + inventoryLoad = .loading + if !more { products = [] } + do { + let result = try await repository.inventory(page: page, search: query, category: filter) + guard active, generation == inventoryGeneration else { return } + let next = try nextPage(result.next, after: page) + var ids = Set() + products = (more ? products + result.results : result.results).filter { ids.insert($0.id).inserted } + nextInventoryPage = next; inventoryHasMore = next != nil; inventoryLoad = .ready + appliedSearch = query; appliedCategory = filter + updated["inventory"] = Date() + if categories.isEmpty { + let result = (try? await repository.categories()) ?? [] + guard active, generation == inventoryGeneration else { return } + categories = result + } + } catch { + guard active, generation == inventoryGeneration else { return } + if discard(error) { products = []; updated["inventory"] = nil } + inventoryLoad = .failed(APIError.display(error)) + } + } + func loadActivity() async { + activityLoad = .loading + do { let result = try await repository.activity(); guard active else { return }; activity = result; updated["activity"] = Date(); activityLoad = .ready } + catch { guard active else { return }; if discard(error) { activity = nil; updated["activity"] = nil }; activityLoad = .failed(APIError.display(error)) } + } + func loadBookings(more: Bool = false) async { + if more && (bookingsLoad == .loading || !bookingsHasMore) { return } + bookingsGeneration += 1; let generation = bookingsGeneration + let page = more ? (nextBookingsPage ?? 1) : 1 + bookingsLoad = .loading + do { + let result = try await repository.bookings(page: page) + guard active, generation == bookingsGeneration else { return } + let next = try nextPage(result.next, after: page) + var tokens = Set() + bookings = (more ? bookings + result.results : result.results).filter { tokens.insert($0.public_token).inserted } + nextBookingsPage = next; bookingsHasMore = next != nil; updated["bookings"] = Date(); bookingsLoad = .ready + } catch { + guard active, generation == bookingsGeneration else { return } + if discard(error) { bookings = []; updated["bookings"] = nil }; bookingsLoad = .failed(APIError.display(error)) + } + } + func loadEvents() async { + eventsLoad = .loading + do { let result = try await repository.events(); guard active else { return }; events = result; updated["events"] = Date(); eventsLoad = .ready } + catch { guard active else { return }; if discard(error) { events = []; updated["events"] = nil }; eventsLoad = .failed(APIError.display(error)) } + } + func loadSpaces() async { + spacesLoad = .loading + do { let result = try await repository.spaces(); guard active else { return }; spaces = result; spacesLoad = .ready } + catch { guard active else { return }; spacesLoad = .failed(APIError.display(error)) } + } + func loadCard() async { + cardGeneration += 1; let generation = cardGeneration + card = nil; cardLoad = .loading + do { let result = try await repository.card(); guard active, generation == cardGeneration else { return }; card = result; cardLoad = .ready } + catch { guard active, generation == cardGeneration else { return }; card = nil; cardLoad = .failed(APIError.display(error)) } + } + func loadInbox(more: Bool = false) async { + if more && (inboxLoad == .loading || !inboxHasMore) { return } + inboxGeneration += 1; let generation = inboxGeneration + let page = more ? (nextInboxPage ?? 1) : 1 + inboxLoad = .loading + if !more { messages = [] } + do { + let result = try await repository.inbox(page: page, unread: unreadOnly) + guard active, generation == inboxGeneration else { return } + let next = try nextPage(result.next, after: page) + var ids = Set() + messages = (more ? messages + result.results : result.results).filter { ids.insert($0.id).inserted } + nextInboxPage = next; inboxHasMore = next != nil; inboxLoad = .ready + updated["inbox"] = Date() + let count = try? await repository.unreadCount() + guard active, generation == inboxGeneration else { return } + unreadCount = count + } catch { + guard active, generation == inboxGeneration else { return } + if discard(error) { messages = []; unreadCount = nil; updated["inbox"] = nil } + inboxLoad = .failed(APIError.display(error)) + } + } + func loadUnreadCount() async { + let count = try? await repository.unreadCount() + guard active else { return }; unreadCount = count + } + func markRead(_ id: Int) async { + guard !inboxMutating else { return }; inboxMutating = true; defer { inboxMutating = false } + do { try await repository.markRead(id); guard active else { return }; await loadInbox() } + catch { if active { inboxLoad = .failed(APIError.display(error)) } } + } + func markAllRead() async { + guard !inboxMutating else { return }; inboxMutating = true; defer { inboxMutating = false } + do { try await repository.markAllRead(); guard active else { return }; await loadInbox() } + catch { if active { inboxLoad = .failed(APIError.display(error)) } } + } + private func discard(_ error: Error) -> Bool { [401, 403, 404].contains(APIError.status(error) ?? 0) } + private func nextPage(_ next: String?, after current: Int) throws -> Int? { + guard let next else { return nil } + guard let components = URLComponents(string: next), + let value = components.queryItems?.first(where: { $0.name == "page" })?.value, + let page = Int(value), page > current else { + throw APIError(status: 0, code: "invalid_next_page", detail: "The server returned an invalid next page.") + } + return page + } +} diff --git a/SpaceWorks/MemberViews.swift b/SpaceWorks/MemberViews.swift new file mode 100644 index 0000000..b7095b0 --- /dev/null +++ b/SpaceWorks/MemberViews.swift @@ -0,0 +1,348 @@ +import SwiftUI +import SpaceWorksAPI +import ImageIO + +struct MemberContent: View { + @Bindable var state: MemberState + let tab: AppTab + let name: String + var selectTab: (AppTab) -> Void = { _ in } + @State private var showCard = false + @Environment(\.dynamicTypeSize) private var textSize + var body: some View { + Group { + switch tab { + case .inventory: InventoryList(state: state) + case .inbox: MemberInbox(state: state) + default: + ScrollView { + VStack(alignment: .leading, spacing: 24) { + if tab == .home { + Text("Welcome back, \(name).").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text("Your makerspace, at a glance.").foregroundStyle(Color.swMuted) + if state.showGuidance { + SWCard { + HStack { Text("Start here").font(.headline); Spacer(); Button("Dismiss") { state.dismissGuidance() }.frame(minHeight: 48) } + Text("Find what you need, then request it from your makerspace.").foregroundStyle(Color.swMuted) + Button("Browse inventory") { selectTab(.inventory) }.buttonStyle(SWPrimary()) + Button("View bookings") { selectTab(.activity) }.frame(minHeight: 48) + } + } + } + LazyVGrid(columns: textSize.isAccessibilitySize ? [GridItem(.flexible())] : [GridItem(.adaptive(minimum: 320), spacing: 24)], alignment: .leading, spacing: 24) { + loans + if tab == .activity { requests } + bookings + events + if tab == .home { + dues + SWCard { + Label("Membership card", systemImage: "person.text.rectangle").font(.title2.bold()) + Text("Keep your membership details close. Ask the front desk for help if your card isn't available.").foregroundStyle(Color.swMuted) + Button("View membership card") { showCard = true }.frame(minHeight: 48) + } + notices + } + } + }.padding(24).frame(maxWidth: 1040, alignment: .leading).frame(maxWidth: .infinity) + }.refreshable { await state.loadHome() } + } + }.background(Color.swBg) + .task(id: tab) { + switch tab { + case .home, .activity: await state.loadHome() + case .inventory: if state.inventoryLoad == .idle { await state.loadInventory() } + case .inbox: if state.inboxLoad == .idle { await state.loadInbox() } + case .profile: break + } + } + .sheet(isPresented: $showCard) { MemberCardView(state: state) } + } + private var loans: some View { + SWCard { + Label("Active loans", systemImage: "shippingbox").font(.title2.bold()) + LoadFeedback(load: state.activityLoad, lastUpdated: state.updated["activity"]) { await state.loadActivity() } + if let activity = state.activity { + if activity.active_hardware_loans.isEmpty { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } + ForEach(activity.active_hardware_loans, id: \.request_id) { loan in + VStack(alignment: .leading, spacing: 8) { + Text(loan.label).font(.headline) + if let due = loan.due_at { Label("Due \(due.formatted(date: .abbreviated, time: .shortened))", systemImage: "calendar") } + if loan.overdue { Label("Overdue", systemImage: "exclamationmark.circle").foregroundStyle(Color.swDanger) } + else { Text("On loan").foregroundStyle(Color.swMuted) } + }.padding(.vertical, 8) + } + } + } + } + private var requests: some View { + SWCard { + Label("Requests", systemImage: "list.clipboard").font(.title2.bold()) + LoadFeedback(load: state.activityLoad) { await state.loadActivity() } + if let rows = state.activity?.request_history, !rows.isEmpty { + ForEach(Array(rows.enumerated()), id: \.offset) { _, row in + VStack(alignment: .leading, spacing: 8) { + Text(row.status.replacingOccurrences(of: "_", with: " ").capitalized).font(.headline) + Text("\(row.item_count) items · \(row.created_at.formatted(date: .abbreviated, time: .omitted))").foregroundStyle(Color.swMuted) + } + } + } else if state.activityLoad == .ready { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } + } + } + private var bookings: some View { + SWCard { + Label("Upcoming bookings", systemImage: "calendar").font(.title2.bold()) + LoadFeedback(load: state.bookingsLoad, lastUpdated: state.updated["bookings"]) { await state.loadBookings() } + if state.bookingsLoad == .ready && state.bookings.isEmpty { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } + ForEach(state.bookings, id: \.public_token) { booking in + VStack(alignment: .leading, spacing: 8) { + Text(booking.space_name).font(.headline) + Text(memberDate(booking.starts_at, timezone: booking.timezone)) + Text(booking.status.rawValue.replacingOccurrences(of: "_", with: " ").capitalized).foregroundStyle(Color.swMuted) + }.padding(.vertical, 8) + } + if state.bookingsHasMore { Button("Load more bookings") { Task { await state.loadBookings(more: true) } }.frame(minHeight: 48).disabled(state.bookingsLoad == .loading) } + } + } + private var events: some View { + SWCard { + Label("Events", systemImage: "ticket").font(.title2.bold()) + LoadFeedback(load: state.eventsLoad, lastUpdated: state.updated["events"]) { await state.loadEvents() } + if state.eventsLoad == .ready && state.events.isEmpty { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } + ForEach(state.events, id: \.public_token) { event in + VStack(alignment: .leading, spacing: 8) { + Text(event.title).font(.headline) + Text(memberDate(event.starts_at, timezone: TimeZone.current.identifier)).foregroundStyle(Color.swMuted) + }.padding(.vertical, 8) + } + } + } + private var notices: some View { + SWCard { + Label("Notices", systemImage: "megaphone").font(.title2.bold()) + if let rows = state.activity?.notices, !rows.isEmpty { + ForEach(Array(rows.enumerated()), id: \.offset) { _, notice in + VStack(alignment: .leading, spacing: 8) { Text(notice.title).font(.headline); Text(notice.body).foregroundStyle(Color.swMuted) } + } + } else if state.activityLoad == .ready { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } + } + } + private var dues: some View { + SWCard { + Label("Dues", systemImage: "creditcard").font(.title2.bold()) + if state.activityLoad == .loading || state.activityLoad == .idle { + LoadFeedback(load: state.activityLoad) { await state.loadActivity() } + } else { + if let dues = state.activity?.membership_dues, let encoded = try? JSONEncoder().encode(dues), + let object = try? JSONSerialization.jsonObject(with: encoded) as? [String: Any], + let amounts = object["outstanding_by_currency"] as? [String: String] { + if amounts.isEmpty { Text("No dues outstanding.") } + ForEach(amounts.keys.sorted(), id: \.self) { currency in Text("\(currency) \(amounts[currency] ?? "")").font(.title2.bold()).monospacedDigit() } + } else { Text("Dues are unavailable. Try again later.").foregroundStyle(Color.swMuted) } + } + } + } +} + +struct LoadFeedback: View { + let load: MemberState.Load + var lastUpdated: Date? + let retry: () async -> Void + var body: some View { + switch load { + case .idle, .loading: + VStack(alignment: .leading, spacing: 12) { + ProgressView("Loading…") + RoundedRectangle(cornerRadius: 4).fill(Color.swSurface).frame(height: 12).accessibilityHidden(true) + RoundedRectangle(cornerRadius: 4).fill(Color.swSurface).frame(maxWidth: 180, minHeight: 12, maxHeight: 12).accessibilityHidden(true) + }.frame(minHeight: 80) + case .failed(let message): + VStack(alignment: .leading) { + SWError(message: message) + if let lastUpdated { Text("Last updated \(lastUpdated.formatted(date: .abbreviated, time: .shortened))").font(.caption).foregroundStyle(Color.swMuted) } + Button("Try again") { Task { await retry() } }.frame(minHeight: 48) + } + case .ready: EmptyView() + } + } +} + +struct InventoryList: View { + @Bindable var state: MemberState + @Environment(\.horizontalSizeClass) private var sizeClass + @Environment(\.dynamicTypeSize) private var textSize + @State private var selectedProduct: Int? + var body: some View { + GeometryReader { geometry in + let split = sizeClass == .regular && geometry.size.width >= 850 && !textSize.isAccessibilitySize + HStack(spacing: 0) { + inventory(split: split).frame(maxWidth: split ? 440 : .infinity) + if split { + Rectangle().fill(Color.swOutline).frame(width: 1) + if let selectedProduct { + InventoryDetail(repository: state.repository, id: selectedProduct).id(selectedProduct) + } else { + ContentUnavailableView("Choose an item", systemImage: "shippingbox", description: Text("See availability and borrowing terms here.")) + .foregroundStyle(Color.swMuted).frame(maxWidth: .infinity) + } + } + } + } + } + private func inventory(split: Bool) -> some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 16) { + Text("Find what you need, then request it from your makerspace.").foregroundStyle(Color.swMuted) + HStack { + TextField("Search inventory", text: $state.search, + prompt: Text("Search inventory").foregroundColor(Color.swMuted)) + .submitLabel(.search).onSubmit { Task { await state.loadInventory() } }.accessibilityIdentifier("inventory-search") + Button("Search") { Task { await state.loadInventory() } }.frame(minHeight: 48) + }.padding(.horizontal, 14).background(Color.swPanel, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)) + Menu { + Button("All categories") { state.category = nil; Task { await state.loadInventory() } } + ForEach(state.categories, id: \.id) { category in + Button(category.name) { state.category = category.slug; Task { await state.loadInventory() } } + } + } label: { Label(state.categories.first(where: { $0.slug == state.category })?.name ?? "All categories", systemImage: "line.3.horizontal.decrease.circle") }.frame(minHeight: 48) + LoadFeedback(load: state.inventoryLoad, lastUpdated: state.updated["inventory"]) { await state.loadInventory() } + if state.inventoryLoad == .ready && state.products.isEmpty { + SWCard { + Text(state.search.isEmpty && state.category == nil ? "No items yet" : "No items match these filters").font(.headline) + if !state.search.isEmpty || state.category != nil { + Button("Clear filters") { state.search = ""; state.category = nil; Task { await state.loadInventory() } }.frame(minHeight: 48) + } + } + } + ForEach(state.products, id: \.id) { product in + if split { + Button { selectedProduct = product.id } label: { productRow(product) } + .buttonStyle(.plain).accessibilityIdentifier("product-\(product.id)") + .accessibilityAddTraits(selectedProduct == product.id ? .isSelected : []) + } else { + NavigationLink { InventoryDetail(repository: state.repository, id: product.id) } label: { productRow(product) } + .buttonStyle(.plain).accessibilityIdentifier("product-\(product.id)") + } + } + if state.inventoryHasMore { Button("Load more") { Task { await state.loadInventory(more: true) } }.buttonStyle(SWPrimary()).disabled(state.inventoryLoad == .loading) } + }.padding(24).frame(maxWidth: 1040, alignment: .leading).frame(maxWidth: .infinity) + }.refreshable { await state.loadInventory() } + } + private func productRow(_ product: Components.Schemas.PublicProduct) -> some View { + SWCard { + HStack(alignment: .top, spacing: 16) { + Image(systemName: "shippingbox").font(.title).frame(width: 48, height: 48).background(Color.swSurface, in: RoundedRectangle(cornerRadius: 10)).accessibilityHidden(true) + VStack(alignment: .leading, spacing: 8) { + Text(product.name).font(.headline) + if let category = product.category_name { Text(category).foregroundStyle(Color.swMuted) } + Text(availability(product)).foregroundStyle(Color.swMuted) + } + Spacer(minLength: 0); Image(systemName: "chevron.right").accessibilityHidden(true) + } + } + } +} + +private func availability(_ product: Components.Schemas.PublicProduct) -> String { + guard let value = product.availability else { return "Ask your makerspace about availability." } + if let count = value.count, value.mode == .exact_count { return "\(count) available" } + return value.label?.rawValue ?? "Ask your makerspace about availability." +} + +private func memberDate(_ date: Date, timezone: String) -> String { + let zone = TimeZone(identifier: timezone) + let formatter = DateFormatter() + formatter.locale = .current; formatter.dateStyle = .medium; formatter.timeStyle = .short + formatter.timeZone = zone ?? TimeZone(secondsFromGMT: 0) + return formatter.string(from: date) + " · " + (zone == nil ? "UTC" : timezone) +} + +struct InventoryDetail: View { + let repository: any MemberRepository + let id: Int + @State private var product: Components.Schemas.PublicProduct? + @State private var error: String? + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + if let product { + Text(product.name).font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text(availability(product)).foregroundStyle(Color.swMuted) + SWCard { Text("About this item").font(.title2.bold()); Text(product.description) } + SWCard { Text("Borrowing terms").font(.title2.bold()); Text(product.borrowing_terms.isEmpty ? "Ask your makerspace about borrowing this item." : product.borrowing_terms) } + } else if let error { + SWCard { SWError(message: error); Button("Try again") { Task { await load() } }.frame(minHeight: 48) } + } else { ProgressView("Loading item…") } + }.padding(24).frame(maxWidth: 800, alignment: .leading).frame(maxWidth: .infinity) + }.background(Color.swBg).navigationTitle("Item details").task { await load() } + .onDisappear { product = nil } + } + private func load() async { + product = nil; error = nil + do { let result = try await repository.product(id); guard !Task.isCancelled else { return }; product = result } + catch { self.error = APIError.display(error) } + } +} + +struct MemberInbox: View { + @Bindable var state: MemberState + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 16) { + Picker("Messages", selection: $state.unreadOnly) { Text("All").tag(false); Text("Unread").tag(true) }.pickerStyle(.segmented) + .onChange(of: state.unreadOnly) { _, _ in Task { await state.loadInbox() } } + Button("Mark all as read") { Task { await state.markAllRead() } }.frame(minHeight: 48).disabled(state.inboxMutating) + LoadFeedback(load: state.inboxLoad, lastUpdated: state.updated["inbox"]) { await state.loadInbox() } + if state.inboxLoad == .ready && state.messages.isEmpty { SWCard { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } } + ForEach(state.messages, id: \.id) { message in + SWCard { + HStack { Text(message.title).font(.headline); Spacer(); if message.read_at == nil { Label("Unread", systemImage: "circle.fill").font(.caption).foregroundStyle(Color.swAccentInk) } } + Text(message.summary).foregroundStyle(Color.swMuted) + Text(message.created_at.formatted(date: .abbreviated, time: .shortened)).font(.caption).foregroundStyle(Color.swMuted) + if message.read_at == nil { Button("Mark as read") { Task { await state.markRead(message.id) } }.frame(minHeight: 48).disabled(state.inboxMutating) } + } + } + if state.inboxHasMore { Button("Load more") { Task { await state.loadInbox(more: true) } }.buttonStyle(SWPrimary()).disabled(state.inboxLoad == .loading) } + }.padding(24).frame(maxWidth: 960, alignment: .leading).frame(maxWidth: .infinity) + }.refreshable { await state.loadInbox() } + } +} + +struct MemberCardView: View { + let state: MemberState + @Environment(\.dismiss) private var dismiss + @Environment(\.scenePhase) private var scenePhase + var body: some View { + NavigationStack { + ScrollView { + SWCard { + if state.repository.isDemo { Text("Demo mode · sample data").font(.caption.bold()).foregroundStyle(Color.swMuted) } + Text("Membership card").font(.largeTitle.bold()) + LoadFeedback(load: state.cardLoad) { await state.loadCard() } + if let card = state.card, card.is_active { + Text(card.printed_name).font(.title2.bold()) + Text("Card \(card.card_number)").monospacedDigit() + if card.qr_active, let image = card.qr_image.flatMap(Self.qrImage) { + Image(uiImage: image).interpolation(.none).resizable().scaledToFit().frame(maxWidth: 320).accessibilityLabel("Membership card QR code") + } else if card.qr_active && state.repository.isDemo { + QRCodeView(value: "spaceworks-demo-member-card", label: "Simulated membership card QR code") + Text("Simulated QR · demo only").foregroundStyle(Color.swMuted) + } else { Text("Your card QR code isn't available. Ask the front desk for help.").foregroundStyle(Color.swMuted) } + } else if state.cardLoad == .ready { Text("Your membership card isn't active. Ask the front desk for help.").foregroundStyle(Color.swMuted) } + }.padding(24).frame(maxWidth: 560).frame(maxWidth: .infinity) + }.background(Color.swBg).toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() }.frame(minHeight: 48) } } + }.privacySensitive().task { await state.loadCard() } + .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await state.loadCard() } } } + } + private static func qrImage(_ value: String) -> UIImage? { + let prefix = "data:image/png;base64," + guard value.hasPrefix(prefix), value.utf8.count <= 2_000_000, + let data = Data(base64Encoded: String(value.dropFirst(prefix.count))), + let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? Int, let height = properties[kCGImagePropertyPixelHeight] as? Int, + (1...2048).contains(width), (1...2048).contains(height) else { return nil } + return UIImage(data: data) + } +} diff --git a/SpaceWorks/NativeAppearance.swift b/SpaceWorks/NativeAppearance.swift index 6f3e800..fad2c5b 100644 --- a/SpaceWorks/NativeAppearance.swift +++ b/SpaceWorks/NativeAppearance.swift @@ -30,6 +30,10 @@ enum NativeAppearance { layout.normal.titleTextAttributes = [.foregroundColor: color("muted")] layout.selected.iconColor = color("accent-ink") layout.selected.titleTextAttributes = [.foregroundColor: color("accent-ink")] + for state in [layout.normal, layout.selected] { + state.badgeBackgroundColor = color("danger") + state.badgeTextAttributes = [.foregroundColor: color("bg")] + } } return appearance } diff --git a/SpaceWorks/PasswordEntry.swift b/SpaceWorks/PasswordEntry.swift index f8dda80..881a1a2 100644 --- a/SpaceWorks/PasswordEntry.swift +++ b/SpaceWorks/PasswordEntry.swift @@ -23,8 +23,16 @@ struct PasswordEntry: UIViewRepresentable { field.tintColor = UIColor(named: "focus") field.returnKeyType = .done field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + field.setContentHuggingPriority(.required, for: .vertical) + field.setContentCompressionResistancePriority(.required, for: .vertical) return field } + func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextField, context: Context) -> CGSize? { + // A UITextField is a single line. Without an explicit fitting height SwiftUI can + // allocate all spare tablet height to the representable inside a ScrollView. + CGSize(width: proposal.width ?? max(1, uiView.intrinsicContentSize.width), + height: max(48, (uiView.font?.lineHeight ?? 20) + 12)) + } func updateUIView(_ field: UITextField, context: Context) { context.coordinator.parent = self field.accessibilityIdentifier = identifier diff --git a/SpaceWorks/Platform/MetadataCamera.swift b/SpaceWorks/Platform/MetadataCamera.swift new file mode 100644 index 0000000..66620ce --- /dev/null +++ b/SpaceWorks/Platform/MetadataCamera.swift @@ -0,0 +1,110 @@ +import AVFoundation +import SwiftUI + +/// AVFoundation fallback for older iOS 17 devices without VisionKit DataScanner hardware support. +struct MetadataCamera: UIViewControllerRepresentable { + let onScan: (String) -> Void + let onUnavailable: () -> Void + + func makeUIViewController(context: Context) -> MetadataScannerController { + MetadataScannerController(onScan: onScan, onUnavailable: onUnavailable) + } + + func updateUIViewController(_ controller: MetadataScannerController, context: Context) { + controller.onScan = onScan + controller.onUnavailable = onUnavailable + } + + static func dismantleUIViewController(_ controller: MetadataScannerController, coordinator: ()) { + controller.stop() + } +} + +final class MetadataScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + var onScan: (String) -> Void + var onUnavailable: () -> Void + private let capture = MetadataCaptureSession() + private var preview: AVCaptureVideoPreviewLayer? + private var delivered = false + + init(onScan: @escaping (String) -> Void, onUnavailable: @escaping () -> Void) { + self.onScan = onScan + self.onUnavailable = onUnavailable + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { return nil } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor(Color.swPanel) + let preview = AVCaptureVideoPreviewLayer(session: capture.session) + preview.videoGravity = .resizeAspectFill + view.layer.addSublayer(preview) + self.preview = preview + capture.start(delegate: self) { [weak self] in self?.onUnavailable() } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + preview?.frame = view.bounds + guard let connection = preview?.connection else { return } + let angle: CGFloat + switch view.window?.windowScene?.interfaceOrientation { + case .landscapeLeft: angle = 180 + case .landscapeRight: angle = 0 + case .portraitUpsideDown: angle = 270 + default: angle = 90 + } + if connection.isVideoRotationAngleSupported(angle) { connection.videoRotationAngle = angle } + } + + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + guard !delivered else { return } + for object in metadataObjects { + guard let code = object as? AVMetadataMachineReadableCodeObject, code.type == .qr, + let value = code.stringValue, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + value.utf8.count <= 2048, + value.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) else { continue } + delivered = true + capture.stop() + onScan(value) + return + } + } + + func stop() { delivered = true; capture.stop() } +} + +/// AVCaptureSession mutation/start/stop are confined to this queue, keeping camera startup off the UI thread. +private final class MetadataCaptureSession: @unchecked Sendable { + let session = AVCaptureSession() + private let queue = DispatchQueue(label: "org.spaceworks.camera.metadata") + + func start(delegate: AVCaptureMetadataOutputObjectsDelegate, unavailable: @escaping @MainActor () -> Void) { + queue.async { [self] in + session.beginConfiguration() + do { + guard let device = AVCaptureDevice.default(for: .video) else { throw CaptureFailure.unavailable } + let input = try AVCaptureDeviceInput(device: device) + let output = AVCaptureMetadataOutput() + guard session.canAddInput(input) else { throw CaptureFailure.unavailable } + session.addInput(input) + guard session.canAddOutput(output) else { throw CaptureFailure.unavailable } + session.addOutput(output) + guard output.availableMetadataObjectTypes.contains(.qr) else { throw CaptureFailure.unavailable } + output.setMetadataObjectsDelegate(delegate, queue: .main) + output.metadataObjectTypes = [.qr] + session.commitConfiguration() + session.startRunning() + if !session.isRunning { Task { @MainActor in unavailable() } } + } catch { + session.commitConfiguration() + Task { @MainActor in unavailable() } + } + } + } + + func stop() { queue.async { [self] in if session.isRunning { session.stopRunning() } } } + private enum CaptureFailure: Error { case unavailable } +} diff --git a/SpaceWorks/Platform/NativeDocumentPreview.swift b/SpaceWorks/Platform/NativeDocumentPreview.swift new file mode 100644 index 0000000..25f3b96 --- /dev/null +++ b/SpaceWorks/Platform/NativeDocumentPreview.swift @@ -0,0 +1,39 @@ +import QuickLook +import SwiftUI +import UIKit + +struct NativeDocumentPreview: UIViewControllerRepresentable { + let file: NativeDocumentFile + func makeCoordinator() -> Coordinator { Coordinator(file: file) } + func makeUIViewController(context: Context) -> QLPreviewController { + let controller = QLPreviewController() + controller.dataSource = context.coordinator + return controller + } + func updateUIViewController(_ controller: QLPreviewController, context: Context) { + if context.coordinator.file.id != file.id { + context.coordinator.file = file + controller.reloadData() + } + } + final class Coordinator: NSObject, QLPreviewControllerDataSource { + var file: NativeDocumentFile + init(file: NativeDocumentFile) { self.file = file } + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 } + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { file.url as NSURL } + } +} + +/// Present with a native sheet so the system supplies the appropriate iPad presentation anchor. +struct NativeDocumentShare: UIViewControllerRepresentable { + let file: NativeDocumentFile + func makeCoordinator() -> Coordinator { Coordinator(file: file) } + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: [file.url], applicationActivities: nil) + } + func updateUIViewController(_ controller: UIActivityViewController, context: Context) {} + final class Coordinator { + let file: NativeDocumentFile + init(file: NativeDocumentFile) { self.file = file } + } +} diff --git a/SpaceWorks/Platform/NativeEvidenceCamera.swift b/SpaceWorks/Platform/NativeEvidenceCamera.swift new file mode 100644 index 0000000..feefc75 --- /dev/null +++ b/SpaceWorks/Platform/NativeEvidenceCamera.swift @@ -0,0 +1,105 @@ +import AVFoundation +import SwiftUI +import UIKit + +enum NativeCameraAvailability: Equatable { case ready, denied, restricted, unavailable } + +@MainActor enum NativeCameraAccess { + /// Call only after the visible evidence flow explains why a photo is needed. + static func request() async throws -> NativeCameraAvailability { + guard UIImagePickerController.isSourceTypeAvailable(.camera) else { return .unavailable } + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: return .ready + case .denied: return .denied + case .restricted: return .restricted + case .notDetermined: + let granted = await AVCaptureDevice.requestAccess(for: .video) + try Task.checkCancellation() + return granted ? .ready : .denied + @unknown default: return .unavailable + } + } +} + +enum NativePhotoError: Error, Equatable { case invalidImage, tooLarge, unavailable } + +enum NativePhotoPreparation { + /// Re-rendering applies orientation and discards original EXIF/GPS metadata before JPEG encoding. + /// Caller supplies the actual server upload cap; this helper never invents an upload policy. + static func jpeg(from image: UIImage, maximumDimension: CGFloat = 1600, + maximumBytes: Int = 5 * 1024 * 1024) throws -> Data { + let width = image.size.width * image.scale + let height = image.size.height * image.scale + guard width.isFinite, height.isFinite, width > 0, height > 0, + maximumDimension.isFinite, (256...4096).contains(maximumDimension), + maximumBytes > 0, maximumBytes <= 20 * 1024 * 1024 else { throw NativePhotoError.invalidImage } + let ratio = min(1, maximumDimension / max(width, height)) + let size = CGSize(width: max(1, floor(width * ratio)), height: max(1, floor(height * ratio))) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + format.opaque = true + format.preferredRange = .standard + let normalized = UIGraphicsImageRenderer(size: size, format: format).image { context in + // Flatten transparency onto a neutral photo background, not a theme-dependent tint. + context.cgContext.setFillColor(CGColor(gray: 1, alpha: 1)) + context.cgContext.fill(CGRect(origin: .zero, size: size)) + image.draw(in: CGRect(origin: .zero, size: size)) + } + for quality in [CGFloat(0.85), 0.7, 0.55, 0.4] { + if let data = normalized.jpegData(compressionQuality: quality), data.count <= maximumBytes { return data } + } + throw NativePhotoError.tooLarge + } +} + +/// Present only after NativeCameraAccess returns .ready. Parent owns dismissal and photo review. +/// No upload, issue/return mutation or automatic evidence confirmation happens in this adapter. +struct NativeEvidenceCamera: UIViewControllerRepresentable { + var maximumBytes = 5 * 1024 * 1024 + let onPhoto: (Result) -> Void + let onCancel: () -> Void + func makeCoordinator() -> Coordinator { Coordinator(parent: self) } + func makeUIViewController(context: Context) -> UIViewController { + guard UIImagePickerController.isSourceTypeAvailable(.camera), + AVCaptureDevice.authorizationStatus(for: .video) == .authorized else { + // Never construct/present the default photo-library picker as a hardware fallback. + let unavailable = UIViewController() + DispatchQueue.main.async { context.coordinator.deliver(.failure(.unavailable)) } + return unavailable + } + let picker = UIImagePickerController() + picker.delegate = context.coordinator + picker.allowsEditing = false + picker.sourceType = .camera + picker.cameraCaptureMode = .photo + picker.mediaTypes = ["public.image"] + return picker + } + func updateUIViewController(_ controller: UIViewController, context: Context) { context.coordinator.parent = self } + static func dismantleUIViewController(_ controller: UIViewController, coordinator: Coordinator) { + (controller as? UIImagePickerController)?.delegate = nil + coordinator.finished = true + } + final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + var parent: NativeEvidenceCamera + var finished = false + init(parent: NativeEvidenceCamera) { self.parent = parent } + func deliver(_ result: Result) { + guard !finished else { return } + finished = true + parent.onPhoto(result) + } + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + guard !finished else { return } + guard let image = info[.originalImage] as? UIImage else { deliver(.failure(.invalidImage)); return } + do { deliver(.success(try NativePhotoPreparation.jpeg(from: image, maximumBytes: parent.maximumBytes))) } + catch let error as NativePhotoError { deliver(.failure(error)) } + catch { deliver(.failure(.invalidImage)) } + } + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + guard !finished else { return } + finished = true + parent.onCancel() + } + } +} diff --git a/SpaceWorks/Platform/NativeFileTransfer.swift b/SpaceWorks/Platform/NativeFileTransfer.swift new file mode 100644 index 0000000..f9f046f --- /dev/null +++ b/SpaceWorks/Platform/NativeFileTransfer.swift @@ -0,0 +1,263 @@ +import Foundation + +enum NativeTransferError: LocalizedError, Equatable { + case invalidURL, invalidInput, tooLarge, refused, unsupportedDocument, failed + var errorDescription: String? { + switch self { + case .invalidURL: return "This file link is unavailable. Request a new link and try again." + case .invalidInput: return "This file could not be prepared. Choose another file and try again." + case .tooLarge: return "This file exceeds the supported size. Try a smaller file or use the web console." + case .refused: return "File access was refused. Request a new link and try again." + case .unsupportedDocument: return "This document could not be opened in the app." + case .failed: return "Couldn't transfer this file. Check your connection and try again." + } + } +} + +/// Local presentation metadata, not an API model. The owner must retain this while previewing/sharing. +final class NativeDocumentFile: Identifiable { + let id = UUID() + let url: URL + init(url: URL) { self.url = url } + deinit { try? FileManager.default.removeItem(at: url) } +} + +enum NativeDocumentKind { + case pdf, jpeg, png, zip + var fileExtension: String { + switch self { case .pdf: return "pdf"; case .jpeg: return "jpg"; case .png: return "png"; case .zip: return "zip" } + } + func accepts(_ mime: String) -> Bool { + switch self { + case .pdf: return mime == "application/pdf" + case .jpeg: return mime == "image/jpeg" + case .png: return mime == "image/png" + case .zip: return ["application/zip", "application/x-zip-compressed", "application/octet-stream"].contains(mime) + } + } + func recognizes(_ data: Data) -> Bool { + switch self { + case .pdf: return data.starts(with: Data("%PDF-".utf8)) + case .jpeg: return data.starts(with: [0xFF, 0xD8, 0xFF]) + case .png: return data.starts(with: [137, 80, 78, 71, 13, 10, 26, 10]) + case .zip: return [[80, 75, 3, 4], [80, 75, 5, 6], [80, 75, 7, 8]].contains { data.starts(with: $0.map(UInt8.init)) } + } + } +} + +/// Only for URLs/fields returned by supported generated API operations after authorization. +/// API requests themselves stay in the generated gateway. Never pass an app access token here. +struct NativeFileTransfer { + private let configuration: URLSessionConfiguration + private let debugHTTPOrigin: URL? + private static let hardLimit = 100 * 1024 * 1024 + + init(configuration: URLSessionConfiguration = .ephemeral, debugHTTPOrigin: URL? = nil) { + let isolated = configuration.copy() as! URLSessionConfiguration + isolated.httpCookieStorage = nil + isolated.urlCredentialStorage = nil + isolated.httpShouldSetCookies = false + isolated.httpAdditionalHeaders = nil + isolated.urlCache = nil + isolated.requestCachePolicy = .reloadIgnoringLocalCacheData + isolated.timeoutIntervalForRequest = 30 + isolated.timeoutIntervalForResource = 120 + self.configuration = isolated + self.debugHTTPOrigin = debugHTTPOrigin + } + + func download(from url: URL, kind: NativeDocumentKind, suggestedName: String, + maximumBytes: Int = 25 * 1024 * 1024) async throws -> NativeDocumentFile { + try validate(url) + try validateLimit(maximumBytes) + var request = URLRequest(url: url) + request.httpMethod = "GET" + let data = try await transfer(request, limit: maximumBytes, accepts: { kind.accepts($0.mimeType ?? "") }) + return try Self.document(data: data, kind: kind, suggestedName: suggestedName, maximumBytes: maximumBytes) + } + + /// Use for bytes returned by a generated authenticated binary operation; no second network fetch. + static func document(data: Data, kind: NativeDocumentKind, suggestedName: String, + maximumBytes: Int = 25 * 1024 * 1024) throws -> NativeDocumentFile { + guard maximumBytes > 0, maximumBytes <= Self.hardLimit else { throw NativeTransferError.invalidInput } + guard data.count <= maximumBytes else { throw NativeTransferError.tooLarge } + guard kind.recognizes(data) else { throw NativeTransferError.unsupportedDocument } + try Task.checkCancellation() + let safeName = Self.filename(suggestedName) + // The UUID prevents path collisions; the extension comes from the expected document kind. + let file = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString + "-" + safeName + "." + kind.fileExtension) + do { try data.write(to: file, options: [.atomic, .completeFileProtection]) } + catch { throw NativeTransferError.failed } + return NativeDocumentFile(url: file) + } + + /// Missing presign method means multipart POST. Send every returned field, then the file last. + func uploadPOST(to url: URL, fields: [String: String], photo: Data, contentType: String, + filename: String, maximumBytes: Int = 20 * 1024 * 1024) async throws { + try validate(url) + try validatePhoto(photo, contentType: contentType, maximumBytes: maximumBytes) + guard fields.count <= 64, fields.reduce(0, { $0 + $1.key.utf8.count + $1.value.utf8.count }) <= 512 * 1024, + fields.allSatisfy({ key, value in + !key.isEmpty && key.utf8.count <= 128 && key != "file" && + key.unicodeScalars.allSatisfy({ CharacterSet.alphanumerics.contains($0) || "_-".unicodeScalars.contains($0) }) && + value.utf8.count <= 64 * 1024 + }) else { throw NativeTransferError.invalidInput } + let boundary = "SpaceWorks-" + UUID().uuidString + var body = Data() + for key in fields.keys.sorted() { + body.append(Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(key)\"\r\n\r\n\(fields[key]!)\r\n".utf8)) + } + let name = Self.filename(filename) + body.append(Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(name)\"\r\nContent-Type: \(contentType)\r\n\r\n".utf8)) + body.append(photo) + body.append(Data("\r\n--\(boundary)--\r\n".utf8)) + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = body + _ = try await transfer(request, limit: 64 * 1024) + } + + /// Use only when the generated presign response explicitly selects PUT. + func uploadPUT(to url: URL, headers: [String: String], photo: Data, contentType: String, + maximumBytes: Int = 20 * 1024 * 1024) async throws { + try validate(url) + try validatePhoto(photo, contentType: contentType, maximumBytes: maximumBytes) + let forbidden: Set = ["authorization", "proxy-authorization", "cookie", "set-cookie", "origin", "referer", + "x-makerspace-id", "host", "content-length", "transfer-encoding", "connection", "keep-alive", "te", "trailer", "upgrade"] + guard headers.count <= 64, headers.allSatisfy({ key, value in + !forbidden.contains(key.lowercased()) && !key.isEmpty && key.utf8.count <= 128 && value.utf8.count <= 8192 && + key.unicodeScalars.allSatisfy({ (33...126).contains($0.value) && !"()<>@,;:\\\"/[]?={} ".unicodeScalars.contains($0) }) && + !value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) && + (key.lowercased() != "content-type" || value == contentType) + }) else { throw NativeTransferError.invalidInput } + var request = URLRequest(url: url) + request.httpMethod = "PUT" + headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + request.httpBody = photo + _ = try await transfer(request, limit: 64 * 1024) + } + + private func validate(_ url: URL) throws { + guard let parts = URLComponents(url: url, resolvingAgainstBaseURL: false), + parts.host?.isEmpty == false, parts.user == nil, parts.password == nil, parts.fragment == nil else { + throw NativeTransferError.invalidURL + } + if parts.scheme == "https" { return } + #if DEBUG + if parts.scheme == "http", ["localhost", "127.0.0.1", "[::1]", "::1"].contains(parts.host?.lowercased() ?? ""), + let origin = debugHTTPOrigin, origin.scheme == "http", origin.host == url.host, origin.port == url.port, + origin.user == nil, origin.password == nil, origin.path.isEmpty || origin.path == "/", + origin.query == nil, origin.fragment == nil { return } + #endif + throw NativeTransferError.invalidURL + } + + private func validateLimit(_ limit: Int) throws { + guard limit > 0, limit <= Self.hardLimit else { throw NativeTransferError.invalidInput } + } + private func validatePhoto(_ data: Data, contentType: String, maximumBytes: Int) throws { + try validateLimit(maximumBytes) + guard data.count <= maximumBytes else { throw NativeTransferError.tooLarge } + let kind: NativeDocumentKind + switch contentType { case "image/jpeg": kind = .jpeg; case "image/png": kind = .png; default: throw NativeTransferError.invalidInput } + guard kind.recognizes(data) else { throw NativeTransferError.invalidInput } + } + private static func filename(_ value: String) -> String { + let scalars = value.unicodeScalars.filter { + (48...57).contains($0.value) || (65...90).contains($0.value) || (97...122).contains($0.value) || "-_".unicodeScalars.contains($0) + }.prefix(80) + let result = String(String.UnicodeScalarView(scalars)) + return result.isEmpty ? "document" : result + } + private func transfer(_ request: URLRequest, limit: Int, + accepts: @escaping (HTTPURLResponse) -> Bool = { _ in true }) async throws -> Data { + let operation = NativeTransferOperation(configuration: configuration, limit: limit, accepts: accepts) + return try await withTaskCancellationHandler { + try Task.checkCancellation() + return try await operation.perform(request) + } onCancel: { operation.cancel() } + } +} + +/// Each transfer owns a separate cookie-free session and bounded response buffer. State is lock-confined. +final class NativeTransferOperation: NSObject, URLSessionDataDelegate, @unchecked Sendable { + private let lock = NSLock() + private let configuration: URLSessionConfiguration + private let limit: Int + private let accepts: (HTTPURLResponse) -> Bool + private var continuation: CheckedContinuation? + private var session: URLSession? + private var cancelled = false + private var buffer = Data() + init(configuration: URLSessionConfiguration, limit: Int, accepts: @escaping (HTTPURLResponse) -> Bool) { + self.configuration = configuration; self.limit = limit; self.accepts = accepts + } + func perform(_ request: URLRequest) async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + lock.lock() + if cancelled { lock.unlock(); continuation.resume(throwing: CancellationError()); return } + self.continuation = continuation + let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) + self.session = session + let task = session.dataTask(with: request) + lock.unlock() + task.resume() + } + } + func cancel() { + lock.lock(); cancelled = true; lock.unlock() + finish(.failure(CancellationError())) + } + private func finish(_ result: Result) { + lock.lock() + let continuation = self.continuation + self.continuation = nil + let session = self.session + self.session = nil + buffer.removeAll(keepingCapacity: false) + lock.unlock() + session?.invalidateAndCancel() + continuation?.resume(with: result) + } + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode) else { + completionHandler(.cancel); finish(.failure(NativeTransferError.refused)); return + } + guard response.expectedContentLength <= Int64(limit) else { + completionHandler(.cancel); finish(.failure(NativeTransferError.tooLarge)); return + } + guard accepts(response) else { + completionHandler(.cancel); finish(.failure(NativeTransferError.unsupportedDocument)); return + } + completionHandler(.allow) + } + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + lock.lock() + guard continuation != nil else { lock.unlock(); return } + if data.count > limit - buffer.count { + lock.unlock(); finish(.failure(NativeTransferError.tooLarge)); return + } + buffer.append(data) + lock.unlock() + } + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + lock.lock(); let data = buffer; let wasCancelled = cancelled; lock.unlock() + if wasCancelled { finish(.failure(CancellationError())) } + else if error != nil { finish(.failure(NativeTransferError.failed)) } + else { finish(.success(data)) } + } + func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { + completionHandler(nil) + finish(.failure(NativeTransferError.refused)) + } + func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + // Use normal system TLS trust. Never consult a shared credential store for an HTTP challenge. + completionHandler(challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust ? .performDefaultHandling : .cancelAuthenticationChallenge, nil) + } +} diff --git a/SpaceWorks/Platform/NativeScanner.swift b/SpaceWorks/Platform/NativeScanner.swift index f3823af..985075d 100644 --- a/SpaceWorks/Platform/NativeScanner.swift +++ b/SpaceWorks/Platform/NativeScanner.swift @@ -9,6 +9,7 @@ struct NativeQRScanner: View { @Environment(\.dismiss) private var dismiss @State private var manual = "" @State private var cameraReady = false + @State private var useDataScanner = false @State private var submitted = false @State private var message: String? @@ -19,10 +20,13 @@ struct NativeQRScanner: View { Text("Scan the QR code, then review the details before confirming.") .foregroundStyle(Color.swMuted) if cameraReady { - ScannerCamera(onScan: complete, onUnavailable: { - cameraReady = false - message = "Camera scanning is unavailable. Enter the code below." - }) + Group { + if useDataScanner { + ScannerCamera(onScan: complete, onUnavailable: cameraUnavailable) + } else { + MetadataCamera(onScan: complete, onUnavailable: cameraUnavailable) + } + } .frame(minHeight: 280, idealHeight: 360, maxHeight: 420) .clipShape(RoundedRectangle(cornerRadius: 16)) .accessibilityLabel("QR code camera") @@ -55,7 +59,7 @@ struct NativeQRScanner: View { } private func prepareCamera() async { - guard DataScannerViewController.isSupported else { + guard AVCaptureDevice.default(for: .video) != nil else { message = "Camera scanning is unavailable on this device. Enter the code below." return } @@ -63,10 +67,16 @@ struct NativeQRScanner: View { if AVCaptureDevice.authorizationStatus(for: .video) == .notDetermined { allowed = await AVCaptureDevice.requestAccess(for: .video) } - cameraReady = allowed && DataScannerViewController.isAvailable + useDataScanner = DataScannerViewController.isSupported && DataScannerViewController.isAvailable + cameraReady = allowed if !cameraReady { message = "Camera access is unavailable. Enter the code below, or allow Camera in Settings." } } + private func cameraUnavailable() { + cameraReady = false + message = "Camera scanning is unavailable. Enter the code below." + } + private func complete(_ value: String) { guard !submitted else { return } let code = value.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/SpaceWorks/RootView.swift b/SpaceWorks/RootView.swift index 9001eaf..116a8bc 100644 --- a/SpaceWorks/RootView.swift +++ b/SpaceWorks/RootView.swift @@ -111,9 +111,30 @@ enum AppTab: String, CaseIterable, Identifiable { struct AppShell: View { let state: AppState let auth: AuthState + var body: some View { + if let membership = auth.selectedMembership, let origin = state.connectedOrigin { + MemberWorkspace(state: state, auth: auth, origin: origin, id: membership.id, slug: membership.slug) + .id("\(origin.url.absoluteString).\(auth.user?.id ?? 0).\(membership.id)") + } + } +} + +private struct MemberWorkspace: View { + let state: AppState + let auth: AuthState + @State private var member: MemberState @Environment(\.horizontalSizeClass) private var sizeClass + @Environment(\.scenePhase) private var scenePhase @State private var selected = AppTab.home + init(state: AppState, auth: AuthState, origin: ServerOrigin, id: Int, slug: String) { + self.state = state; self.auth = auth + _member = State(initialValue: MemberState(repository: LiveMemberRepository(service: auth.service, origin: origin, id: id, slug: slug, + onUnauthorized: { [weak auth] message in await auth?.invalidateSession(message: message) }, + onForbidden: { [weak auth] in await auth?.foreground() }), + settings: .standard, context: "\(origin.url.absoluteString).\(auth.user?.id ?? 0).\(id)")) + } var body: some View { + VStack(spacing: 0) { if sizeClass == .regular { NavigationSplitView { List(AppTab.allCases) { tab in @@ -125,33 +146,37 @@ struct AppShell: View { } .scrollContentBackground(.hidden).background(Color.swSidebar) .navigationTitle(Brand.appName) - } detail: { NavigationStack { MemberScreen(state: state, auth: auth, tab: selected) } } + } detail: { NavigationStack { MemberScreen(state: state, auth: auth, member: member, tab: selected) { selected = $0 } } } } else { TabView(selection: $selected) { ForEach(AppTab.allCases) { tab in - NavigationStack { MemberScreen(state: state, auth: auth, tab: tab) } + NavigationStack { MemberScreen(state: state, auth: auth, member: member, tab: tab) { selected = $0 } } .tabItem { Label(tab.rawValue, systemImage: tab.symbol) }.tag(tab) + .badge(tab == .inbox ? member.unreadCount ?? 0 : 0) } } } + }.task { await member.loadUnreadCount() }.onDisappear { member.invalidate() } + .onChange(of: scenePhase) { _, phase in + if phase == .active { Task { await member.loadHome(); await member.loadUnreadCount() } } + } } } private struct MemberScreen: View { let state: AppState let auth: AuthState + let member: MemberState let tab: AppTab + let selectTab: (AppTab) -> Void @State private var accountForm: AccountAccessState? @State private var replayIntro = false @State private var confirmSignOut = false var body: some View { - ScrollView { + Group { + if tab != .profile { MemberContent(state: member, tab: tab, name: auth.displayName, selectTab: selectTab) } + else { ScrollView { VStack(alignment: .leading, spacing: 24) { - if tab == .home { - Text("Welcome back, \(auth.displayName).").font(.largeTitle.bold()) - Text("Your makerspace, in your pocket.").foregroundStyle(Color.swMuted) - } - if tab == .profile { SWCard { Text(auth.displayName).font(.title2.bold()) Text(auth.user?.email ?? "").foregroundStyle(Color.swMuted).textSelection(.enabled) @@ -160,15 +185,11 @@ private struct MemberScreen: View { Button("Confirm email") { accountForm = auth.accountForm(.emailVerification) }.frame(minHeight: 48) } Button("Show introduction") { replayIntro = true }.frame(minHeight: 48) + Button("Show helpful tips") { member.replayGuidance() }.frame(minHeight: 48) Button("Sign out", role: .destructive) { confirmSignOut = true }.frame(minHeight: 48).disabled(auth.busy) } - } else { - SWCard { - Image(systemName: tab.symbol).font(.title).accessibilityHidden(true) - Text("Nothing here yet.").font(.headline) - } - } }.padding(24).frame(maxWidth: 960, alignment: .leading).frame(maxWidth: .infinity) + } } }.background(Color.swBg).navigationTitle(tab.rawValue) .sheet(item: $accountForm) { form in AccountAccessView(form: form) { accountForm = nil } } .sheet(isPresented: $replayIntro) { IntroductionView { replayIntro = false } } @@ -180,7 +201,11 @@ private struct MemberScreen: View { ToolbarItem(placement: .topBarTrailing) { Menu { ForEach(auth.memberships, id: \.id) { membership in - Button(membership.slug) { Task { await auth.select(membership.id) } } + Button(membership.slug) { + guard auth.selectedID != membership.id else { return } + member.invalidate() + Task { await auth.select(membership.id) } + } } } label: { Label(auth.selectedMembership?.slug ?? "Choose a makerspace", systemImage: "building.2") } .accessibilityIdentifier("makerspace-switcher") diff --git a/SpaceWorks/SpaceWorksApp.swift b/SpaceWorks/SpaceWorksApp.swift index e446235..1e1377d 100644 --- a/SpaceWorks/SpaceWorksApp.swift +++ b/SpaceWorks/SpaceWorksApp.swift @@ -12,6 +12,7 @@ struct SpaceWorksApp: App { } #if DEBUG if ProcessInfo.processInfo.arguments.contains("--ui-testing") { + PreviewMemberResponses.reset() UserDefaults.standard.removeObject(forKey: "server-origin") UserDefaults.standard.removeObject(forKey: "dark-mode") UserDefaults.standard.removeObject(forKey: "makerspace-selection") @@ -66,6 +67,7 @@ final class PreviewHealthProtocol: URLProtocol { let code = failure ? 503 : 200 let body: String if failure { body = #"{"detail":"Try again later.","code":"service_unavailable"}"# } + else if let member = PreviewMemberResponses.response(for: request) { body = member } else { switch path { case "/api/v1/config": body = DebugAuthFixtures.config @@ -86,4 +88,50 @@ final class PreviewHealthProtocol: URLProtocol { } override func stopLoading() {} } + +/// Test-only server state, separated from the interactive demo and absent from Release. +private enum PreviewMemberResponses { + static let lock = NSLock() + static var readIDs = Set() + static func reset() { lock.lock(); readIDs = []; lock.unlock() } + static func response(for request: URLRequest) -> String? { + guard let url = request.url else { return nil } + let path = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if path.hasSuffix("inventory/categories") { return DebugMemberFixtures.categories } + if path.contains("/inventory/") { + return first(DebugMemberFixtures.inventory, id: Int(path.split(separator: "/").last ?? "")) + } + if path.hasSuffix("inventory") { return DebugMemberFixtures.inventory } + if path.hasSuffix("activity") { return DebugMemberFixtures.activity } + if path.hasSuffix("my-bookings") { return DebugMemberFixtures.bookings } + if path.hasSuffix("events") { return DebugMemberFixtures.events } + if path.contains("/events/") { return first(DebugMemberFixtures.events) } + if path.hasSuffix("spaces") { return DebugMemberFixtures.spaces } + if path.hasSuffix("member-card") { return DebugMemberFixtures.card } + guard path.contains("notifications/inbox/") else { return nil } + lock.lock(); defer { lock.unlock() } + var object = (try? JSONSerialization.jsonObject(with: Data(DebugMemberFixtures.inbox.utf8))) as? [String: Any] ?? [:] + var rows = object["results"] as? [[String: Any]] ?? [] + if path.hasSuffix("read-all") { + readIDs.formUnion(rows.compactMap { $0["id"] as? Int }); return #"{"updated":2}"# + } + if path.hasSuffix("read"), let id = Int(path.split(separator: "/").dropLast().last ?? "") { + readIDs.insert(id) + var row = rows.first { $0["id"] as? Int == id } ?? [:] + row["read_at"] = "2026-10-16T10:00:00Z"; return json(row) + } + for index in rows.indices where readIDs.contains(rows[index]["id"] as? Int ?? 0) { rows[index]["read_at"] = "2026-10-16T10:00:00Z" } + let unread = rows.filter { $0["read_at"] is NSNull } + if path.hasSuffix("unread-count") { return json(["count": unread.count]) } + if URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.contains(where: { $0.name == "unread" && $0.value == "true" }) == true { rows = unread } + object["results"] = rows; object["count"] = rows.count + return json(object) + } + private static func first(_ fixture: String, id: Int? = nil) -> String { + let object = try? JSONSerialization.jsonObject(with: Data(fixture.utf8)) + let rows = (object as? [[String: Any]]) ?? ((object as? [String: Any])?["results"] as? [[String: Any]]) ?? [] + return json(rows.first(where: { id == nil || $0["id"] as? Int == id }) ?? [:]) + } + private static func json(_ object: Any) -> String { String(decoding: (try? JSONSerialization.data(withJSONObject: object)) ?? Data("{}".utf8), as: UTF8.self) } +} #endif diff --git a/SpaceWorksTests/AppStateTests.swift b/SpaceWorksTests/AppStateTests.swift index 4e34351..7474207 100644 --- a/SpaceWorksTests/AppStateTests.swift +++ b/SpaceWorksTests/AppStateTests.swift @@ -136,6 +136,7 @@ final class AppStateTests: XCTestCase { for state in [layout.normal, layout.selected] { try check(state.titleTextAttributes[.foregroundColor] as? UIColor, on: tabs.backgroundColor, traits: traits) try check(state.iconColor, on: tabs.backgroundColor, traits: traits) + try check(state.badgeTextAttributes[.foregroundColor] as? UIColor, on: state.badgeBackgroundColor, traits: traits) } } } diff --git a/SpaceWorksTests/MemberNetworkingTests.swift b/SpaceWorksTests/MemberNetworkingTests.swift new file mode 100644 index 0000000..65a536a --- /dev/null +++ b/SpaceWorksTests/MemberNetworkingTests.swift @@ -0,0 +1,92 @@ +import XCTest +import Foundation +import SpaceWorksAPI +@testable import SpaceWorks + +final class MemberNetworkingTests: XCTestCase { + private func first(_ text: String) throws -> String { + let object = try JSONSerialization.jsonObject(with: Data(text.utf8)) + let rows = (object as? [[String: Any]]) ?? ((object as! [String: Any])["results"] as! [[String: Any]]) + return String(decoding: try JSONSerialization.data(withJSONObject: rows[0]), as: UTF8.self) + } + private func service(handler: @escaping (URLRequest) throws -> String) throws -> SpaceWorksService { + StubProtocol.lock.lock() + StubProtocol.handler = { request in (200, ["Content-Type": "application/json"], Data(try handler(request).utf8)) } + StubProtocol.lock.unlock() + let config = DeviceSession.configuration(); config.protocolClasses = [StubProtocol.self] + return SpaceWorksService(origin: try ServerOrigin("https://spaceworks.example"), session: DeviceSession.make(configuration: config), + initialTokens: TokenPair(access: "member-access", refresh: "member-refresh")) + } + func testEveryMemberReadAndInboxMutationUsesGeneratedRouteAndSelectedHeader() async throws { + var paths = Set() + var calls = 0 + let product = try first(DebugMemberFixtures.inventory) + let event = try first(DebugMemberFixtures.events) + let message = try first(DebugMemberFixtures.inbox) + let eventToken = (try JSONSerialization.jsonObject(with: Data(event.utf8)) as! [String: Any])["public_token"] as! String + let client = try service { request in + XCTAssertEqual(request.url?.host, "spaceworks.example") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer member-access") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Makerspace-Id"), "7") + XCTAssertNil(request.value(forHTTPHeaderField: "Cookie")); XCTAssertNil(request.value(forHTTPHeaderField: "X-Publishable-Key")) + let path = request.url!.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + paths.insert(path) + calls += 1 + switch path { + case _ where path.hasSuffix("inventory/categories"): return DebugMemberFixtures.categories + case _ where path.hasSuffix("inventory"): + let query = URLComponents(url: request.url!, resolvingAgainstBaseURL: true)!.queryItems! + XCTAssertEqual(query.first(where: { $0.name == "page" })?.value, "1") + XCTAssertEqual(query.first(where: { $0.name == "q" })?.value, "multimeter") + XCTAssertEqual(query.first(where: { $0.name == "category" })?.value, "electronics") + return DebugMemberFixtures.inventory + case _ where path.contains("/inventory/"): return product + case _ where path.hasSuffix("activity"): return DebugMemberFixtures.activity + case _ where path.hasSuffix("my-bookings"): return DebugMemberFixtures.bookings + case _ where path.hasSuffix("events"): return DebugMemberFixtures.events + case _ where path.contains("/events/"): return event + case _ where path.hasSuffix("spaces"): return DebugMemberFixtures.spaces + case _ where path.hasSuffix("member-card"): return DebugMemberFixtures.card + case _ where path.hasSuffix("unread-count"): return #"{"count":1}"# + case _ where path.hasSuffix("read-all"): + XCTAssertEqual(request.httpMethod, "POST"); return #"{"updated":1}"# + case _ where path.hasSuffix("read"): + XCTAssertEqual(request.httpMethod, "POST"); return message + case _ where path.hasSuffix("inbox/7"): return DebugMemberFixtures.inbox + default: XCTFail("Unexpected generated operation: \(path)"); return "{}" + } + } + try await client.makerspace.select(7, activeMemberships: [7, 9]) + _ = try await client.memberInventory(slug: "makerspace-one", page: 1, search: "multimeter", category: "electronics") + _ = try await client.memberProduct(slug: "makerspace-one", id: 1) + _ = try await client.memberCategories(slug: "makerspace-one") + _ = try await client.memberActivity(id: 7) + _ = try await client.memberBookings(slug: "makerspace-one", page: 1) + _ = try await client.memberEvents(slug: "makerspace-one") + _ = try await client.memberEvent(slug: "makerspace-one", token: eventToken) + _ = try await client.memberSpaces(slug: "makerspace-one") + _ = try await client.memberCard(id: 7) + _ = try await client.memberInbox(id: 7, page: 1, unread: true) + try await client.memberMarkRead(id: 7, message: 1) + try await client.memberMarkAllRead(id: 7) + _ = try await client.memberUnreadCount(id: 7) + XCTAssertEqual(paths.count, 13) + XCTAssertEqual(calls, 13) + try await client.makerspace.select(9, activeMemberships: [7, 9]) + do { + _ = try await client.withMakerspace(7) { try await client.memberActivity(id: 7) } + XCTFail("An old repository must not send its path with a new makerspace header") + } catch {} + XCTAssertEqual(paths.count, 13) + XCTAssertEqual(calls, 13, "A context mismatch must fail before transport") + } + func testLiveRepositoryRejectsCrossOriginPaginationWithoutFollowingIt() async throws { + let client = try service { _ in + DebugMemberFixtures.inventory.replacingOccurrences(of: "\"next\": null", with: "\"next\": \"https://other.example/api/v1/inventory/?page=2\"") + } + try await client.makerspace.select(7, activeMemberships: [7]) + let repository = LiveMemberRepository(service: client, origin: try ServerOrigin("https://spaceworks.example"), id: 7, slug: "makerspace-one") + do { _ = try await repository.inventory(page: 1, search: "", category: nil); XCTFail("Cross-origin next URL was accepted") } + catch { XCTAssertEqual(APIError.code(error), "invalid_next_page") } + } +} diff --git a/SpaceWorksTests/MemberTests.swift b/SpaceWorksTests/MemberTests.swift new file mode 100644 index 0000000..27a0bd3 --- /dev/null +++ b/SpaceWorksTests/MemberTests.swift @@ -0,0 +1,111 @@ +import XCTest +import SpaceWorksAPI +@testable import SpaceWorks + +@MainActor final class MemberTests: XCTestCase { + func testGeneratedDemoFixturesPopulateHomeAndSupportInventoryPaginationAndFilters() async throws { + let repository = DemoMemberRepository() + let state = MemberState(repository: repository) + await state.loadHome() + XCTAssertEqual(state.activityLoad, .ready); XCTAssertEqual(state.bookingsLoad, .ready) + XCTAssertEqual(state.eventsLoad, .ready); XCTAssertEqual(state.cardLoad, .ready) + XCTAssertEqual(state.card?.printed_name, "Meera Nair"); XCTAssertFalse(state.bookings.isEmpty) + await state.loadInventory(); XCTAssertEqual(state.products.count, 6); XCTAssertTrue(state.inventoryHasMore) + await state.loadInventory(more: true); XCTAssertEqual(state.products.count, 12); XCTAssertFalse(state.inventoryHasMore) + state.search = "multimeter"; await state.loadInventory() + XCTAssertEqual(state.products.count, 1); XCTAssertEqual(state.products.first?.name, "Digital multimeter") + state.search = "no match"; await state.loadInventory(); XCTAssertTrue(state.products.isEmpty) + XCTAssertEqual(state.inventoryLoad, .ready) + let spaces = try await repository.spaces(); XCTAssertFalse(spaces.isEmpty) + } + func testInboxReadActionsRefreshServerConfirmedState() async { + let state = MemberState(repository: DemoMemberRepository()) + state.unreadOnly = true; await state.loadInbox() + XCTAssertEqual(state.messages.count, 1); XCTAssertEqual(state.unreadCount, 1) + await state.markRead(state.messages[0].id) + XCTAssertTrue(state.messages.isEmpty); XCTAssertEqual(state.unreadCount, 0) + state.unreadOnly = false; await state.loadInbox() + XCTAssertEqual(state.messages.count, 2); XCTAssertTrue(state.messages.allSatisfy { $0.read_at != nil }) + await state.markAllRead(); XCTAssertEqual(state.unreadCount, 0) + } + func testEmptyErrorAndRevokedCardStatesRemainDistinct() async { + let empty = MemberState(repository: DemoMemberRepository(scenario: .empty)) + await empty.loadHome(); await empty.loadInventory(); await empty.loadInbox() + XCTAssertEqual(empty.inventoryLoad, .ready); XCTAssertTrue(empty.products.isEmpty) + XCTAssertTrue(empty.bookings.isEmpty); XCTAssertTrue(empty.events.isEmpty) + XCTAssertNil(empty.card) + if case .failed = empty.cardLoad {} else { XCTFail("Not-issued card must not render an active QR") } + let offline = MemberState(repository: DemoMemberRepository(scenario: .offline)) + await offline.loadHome() + if case .failed = offline.activityLoad {} else { XCTFail("Transport failure must not become empty success") } + XCTAssertNil(offline.activity); XCTAssertNil(offline.card) + let revoked = MemberState(repository: DemoMemberRepository(scenario: .revokedCard)) + await revoked.loadCard(); XCTAssertEqual(revoked.card?.is_active, false); XCTAssertEqual(revoked.card?.qr_active, false) + } + func testInvalidationPreventsLateCardFromReappearing() async throws { + let gate = CardGate() + let state = MemberState(repository: GatedMemberRepository(gate: gate)) + let pending = Task { await state.loadCard() } + await gate.waitUntilRequested() + state.invalidate() + await gate.resolve(try await DemoMemberRepository().card()) + await pending.value + XCTAssertNil(state.card); XCTAssertTrue(state.products.isEmpty); XCTAssertNil(state.unreadCount) + } + func testLatestCardRefusalWinsOverEarlierSuccessfulRequest() async throws { + let gate = CardGate() + let state = MemberState(repository: GatedMemberRepository(gate: gate)) + let first = Task { await state.loadCard() } + await gate.waitUntilRequested() + await state.loadCard() + await gate.resolve(try await DemoMemberRepository().card()); await first.value + XCTAssertNil(state.card) + if case .failed = state.cardLoad {} else { XCTFail("Late success must not resurrect a refused QR") } + } + func testGuidanceIsScopedToAccountAndMakerspaceAndDemoStaysInMemory() { + let settings = UserDefaults(suiteName: UUID().uuidString)! + let first = MemberState(repository: DemoMemberRepository(), settings: settings, context: "server/account/space7") + first.dismissGuidance() + let restored = MemberState(repository: DemoMemberRepository(), settings: settings, context: "server/account/space7") + XCTAssertFalse(restored.showGuidance) + let other = MemberState(repository: DemoMemberRepository(), settings: settings, context: "server/account/space9") + XCTAssertTrue(other.showGuidance) + let demo = MemberState(repository: DemoMemberRepository()); demo.dismissGuidance() + XCTAssertTrue(MemberState(repository: DemoMemberRepository()).showGuidance) + } +} + +private actor CardGate { + private var pending: CheckedContinuation? + private var observer: CheckedContinuation? + private var requested = false + func card() async throws -> Components.Schemas.OwnMemberCard { + if requested { throw APIError(status: 403, code: "card_revoked", detail: "Your card is no longer active.") } + requested = true + return try await withCheckedThrowingContinuation { continuation in + pending = continuation; observer?.resume(); observer = nil + } + } + func waitUntilRequested() async { + if pending != nil { return } + await withCheckedContinuation { observer = $0 } + } + func resolve(_ card: Components.Schemas.OwnMemberCard) { pending?.resume(returning: card); pending = nil } +} + +private struct GatedMemberRepository: MemberRepository { + let gate: CardGate + let base = DemoMemberRepository() + func inventory(page: Int, search: String, category: String?) async throws -> Components.Schemas.PaginatedPublicProductList { try await base.inventory(page: page, search: search, category: category) } + func product(_ id: Int) async throws -> Components.Schemas.PublicProduct { try await base.product(id) } + func categories() async throws -> [Components.Schemas.PublicCategory] { try await base.categories() } + func activity() async throws -> Components.Schemas.MemberActivity { try await base.activity() } + func bookings(page: Int) async throws -> Components.Schemas.MemberBookingList { try await base.bookings(page: page) } + func events() async throws -> [Components.Schemas.PublicEvent] { try await base.events() } + func spaces() async throws -> [Components.Schemas.PublicBookableSpace] { try await base.spaces() } + func card() async throws -> Components.Schemas.OwnMemberCard { try await gate.card() } + func inbox(page: Int, unread: Bool) async throws -> Components.Schemas.InboxPage { try await base.inbox(page: page, unread: unread) } + func markRead(_ id: Int) async throws { try await base.markRead(id) } + func markAllRead() async throws { try await base.markAllRead() } + func unreadCount() async throws -> Int { try await base.unreadCount() } +} diff --git a/SpaceWorksTests/NativeFileTransferTests.swift b/SpaceWorksTests/NativeFileTransferTests.swift new file mode 100644 index 0000000..d2c2b95 --- /dev/null +++ b/SpaceWorksTests/NativeFileTransferTests.swift @@ -0,0 +1,157 @@ +import Foundation +import XCTest +@testable import SpaceWorks + +private final class TransferStub: URLProtocol { + static let lock = NSLock() + static var handler: ((URLRequest) throws -> (Int, [String: String], [Data]))? + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + do { + Self.lock.lock(); let handler = Self.handler; Self.lock.unlock() + let (status, headers, chunks) = try XCTUnwrap(handler)(request) + let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: headers)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + chunks.forEach { client?.urlProtocol(self, didLoad: $0) } + client?.urlProtocolDidFinishLoading(self) + } catch { client?.urlProtocol(self, didFailWithError: error) } + } + override func stopLoading() {} +} + +final class NativeFileTransferTests: XCTestCase { + private let url = URL(string: "https://storage.example/evidence?signature=private")! + private let jpeg = Data([0xFF, 0xD8, 0xFF, 0xD9]) + private func transfer(_ handler: @escaping (URLRequest) throws -> (Int, [String: String], [Data])) -> NativeFileTransfer { + TransferStub.lock.lock(); TransferStub.handler = handler; TransferStub.lock.unlock() + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [TransferStub.self] + // Deliberately dirty source configuration must not leak into the storage session. + configuration.httpAdditionalHeaders = ["Authorization": "Bearer app-token", "Cookie": "session=web", "X-Makerspace-Id": "9"] + return NativeFileTransfer(configuration: configuration) + } + override func tearDown() { + TransferStub.lock.lock(); TransferStub.handler = nil; TransferStub.lock.unlock() + } + private func assertNoAppHeaders(_ request: URLRequest) { + for name in ["Authorization", "Cookie", "Origin", "Referer", "X-Makerspace-Id"] { + XCTAssertNil(request.value(forHTTPHeaderField: name), name) + } + } + + func testMultipartPreservesEverySignedFieldAndPlacesPhotoLastWithoutAppCredentials() async throws { + let client = transfer { request in + self.assertNoAppHeaders(request) + XCTAssertEqual(request.httpMethod, "POST") + let body = try XCTUnwrap(String(data: requestBody(request), encoding: .isoLatin1)) + XCTAssertTrue(body.contains("name=\"key\"\r\n\r\nfolder/photo.jpg\r\n")) + XCTAssertTrue(body.contains("name=\"policy\"\r\n\r\nsigned-policy\r\n")) + XCTAssertTrue(body.contains("name=\"x-amz-signature\"\r\n\r\nsigned-value\r\n")) + let photo = try XCTUnwrap(body.range(of: "name=\"file\";")) + for key in ["key", "policy", "x-amz-signature"] { + XCTAssertLessThan(try XCTUnwrap(body.range(of: "name=\"\(key)\"" )).lowerBound, photo.lowerBound) + } + XCTAssertFalse(body.contains("filename=\"../../")) + return (204, [:], []) + } + try await client.uploadPOST(to: url, fields: ["key": "folder/photo.jpg", "policy": "signed-policy", "x-amz-signature": "signed-value"], + photo: jpeg, contentType: "image/jpeg", filename: "../../evidence.jpg") + } + + func testExplicitPUTSendsRawImageAndReturnedStorageHeadersOnly() async throws { + let client = transfer { request in + self.assertNoAppHeaders(request) + XCTAssertEqual(request.httpMethod, "PUT") + XCTAssertEqual(requestBody(request), self.jpeg) + XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "image/jpeg") + XCTAssertEqual(request.value(forHTTPHeaderField: "x-amz-meta-object"), "server-value") + return (200, [:], []) + } + try await client.uploadPUT(to: url, headers: ["x-amz-meta-object": "server-value", "Content-Type": "image/jpeg"], + photo: jpeg, contentType: "image/jpeg") + } + + func testRejectsCredentialAndHeaderInjectionBeforeOpeningStorageConnection() async throws { + let client = transfer { _ in XCTFail("Unsafe input must not open a connection"); return (200, [:], []) } + for headers in [["Authorization": "Bearer leak"], ["Cookie": "session=leak"], ["X-Makerspace-Id": "3"], + ["X-Test": "a\r\nAuthorization: secret"], ["Content-Type": "text/html"]] { + do { + try await client.uploadPUT(to: url, headers: headers, photo: jpeg, contentType: "image/jpeg") + XCTFail("Unsafe headers accepted") + } catch { XCTAssertEqual(error as? NativeTransferError, .invalidInput) } + } + do { + try await client.uploadPOST(to: url, fields: ["file": "shadow-photo"], photo: jpeg, contentType: "image/jpeg", filename: "photo") + XCTFail("Duplicate file field accepted") + } catch { XCTAssertEqual(error as? NativeTransferError, .invalidInput) } + } + + func testRejectsUnsafeURLAndOversizedPhotoBeforeOpeningConnection() async throws { + let client = transfer { _ in XCTFail("Unsafe input must not open a connection"); return (200, [:], []) } + for address in ["http://storage.example/photo", "https://user:password@storage.example/photo", "https://storage.example/photo#fragment", "file:///private/photo"] { + do { + try await client.uploadPUT(to: URL(string: address)!, headers: [:], photo: jpeg, contentType: "image/jpeg") + XCTFail("Unsafe URL accepted") + } catch { XCTAssertEqual(error as? NativeTransferError, .invalidURL) } + } + do { + try await client.uploadPUT(to: url, headers: [:], photo: jpeg, contentType: "image/jpeg", maximumBytes: 3) + XCTFail("Oversized photo accepted") + } catch { XCTAssertEqual(error as? NativeTransferError, .tooLarge) } + } + + func testDownloadCreatesProtectedLocalPDFAndDeletesItAfterPresentationOwnerReleases() async throws { + let bytes = Data("%PDF-1.7\nfixture".utf8) + let client = transfer { request in + self.assertNoAppHeaders(request) + XCTAssertEqual(request.httpMethod, "GET") + return (200, ["Content-Type": "application/pdf"], [bytes]) + } + var file: NativeDocumentFile? = try await client.download(from: url, kind: .pdf, suggestedName: "../../Receipt"#, + #"]>&secret;"#, + #""#, + #""#, + #""#, + #""#, + #""#, + #""# + ] + for value in unsafe { XCTAssertNil(image(value)) } + XCTAssertNil(NativeQRSVG.image(Data(repeating: 32, count: 512 * 1024 + 1))) + } +} diff --git a/SpaceWorksTests/NetworkingTests.swift b/SpaceWorksTests/NetworkingTests.swift index 44c8eac..454ab65 100644 --- a/SpaceWorksTests/NetworkingTests.swift +++ b/SpaceWorksTests/NetworkingTests.swift @@ -1,5 +1,7 @@ import XCTest import Foundation +import HTTPTypes +import OpenAPIRuntime import SpaceWorksAPI final class StubProtocol: URLProtocol { @@ -74,6 +76,7 @@ final class NetworkingTests: XCTestCase { return (200, self.json, Data(#"{"status":"ok"}"#.utf8)) } try await client.makerspace.select(7, activeMemberships: [7]) + let oldCredentials = await client.refresh.snapshot() try await withThrowingTaskGroup(of: Void.self) { group in for _ in 0..<24 { group.addTask { @@ -86,11 +89,51 @@ final class NetworkingTests: XCTestCase { } XCTAssertEqual(counter.value, 1) // A late response rejected with the old access token must not rotate again. - let recovered = try await client.refresh.recover(rejectedAccess: "old-access") + let recovered = try await client.refresh.recover(rejected: oldCredentials) XCTAssertEqual(recovered.refresh, "new-refresh") XCTAssertEqual(counter.value, 1) } + func testDelayed401CannotReplayMutationUnderNewAccountWithSameMakerspace() async throws { + let gate = NetworkEpochGate(); let calls = LockedCounter(); let rotations = LockedCounter() + let refresh = RefreshCoordinator(pair: TokenPair(access: "old-account", refresh: "old-refresh"), rotate: { _ in rotations.increment(); return TokenPair(access: "rotated", refresh: "rotated-refresh") }) + let makerspace = MakerspaceContext(); try await makerspace.select(7, activeMemberships: [7]) + let middleware = SessionMiddleware(refresh: refresh, makerspace: makerspace) + let request = HTTPRequest(method: .post, scheme: "https", authority: "spaceworks.example", path: "/api/v1/mutation/") + let task = Task { + try await middleware.intercept(request, body: nil, baseURL: self.origin.url, operationID: "mutation") { sent, _, _ in + calls.increment(); XCTAssertEqual(sent.headerFields[.authorization], "Bearer old-account") + await gate.arrive() + return (HTTPResponse(status: .unauthorized), nil) + } + } + await gate.waitUntilEntered() + try await refresh.install(nil) + try await makerspace.select(nil, activeMemberships: []) + try await refresh.install(TokenPair(access: "new-account", refresh: "new-refresh")) + try await makerspace.select(7, activeMemberships: [7]) + await gate.release() + do { _ = try await task.value; XCTFail("Old mutation must not retry under another installed session") } catch {} + XCTAssertEqual(calls.value, 1); XCTAssertEqual(rotations.value, 0) + let current = await refresh.current(); XCTAssertEqual(current?.access, "new-account") + } + + func testQueuedScopedOperationCannotFirstSendAsAnotherInstalledAccount() async throws { + let gate = NetworkEpochGate(); let calls = LockedCounter() + let client = service(tokens: TokenPair(access: "old", refresh: "old-refresh")) { _ in + calls.increment(); return (200, self.json, Data(#"{"status":"ok"}"#.utf8)) + } + try await client.makerspace.select(7, activeMemberships: [7]) + let task = Task { try await client.withMakerspace(7) { await gate.arrive(); try await client.health() } } + await gate.waitUntilEntered() + try await client.refresh.install(nil) + try await client.refresh.install(TokenPair(access: "new", refresh: "new-refresh")) + try await client.makerspace.select(7, activeMemberships: [7]) + await gate.release() + do { try await task.value; XCTFail("Queued old operation must be cancelled before transport") } catch {} + XCTAssertEqual(calls.value, 0) + } + func testRefreshFailureSignsOutWithoutReusingRefreshToken() async throws { let counter = LockedCounter() let client = service(tokens: TokenPair(access: "old", refresh: "single-use")) { request in @@ -134,6 +177,7 @@ final class NetworkingTests: XCTestCase { for (status, body, expected) in [ (403, #"{"detail":"Membership is inactive."}"#, "Membership is inactive."), (400, #"{"email":["Enter a valid email."],"name":["This field is required."]}"#, "email: Enter a valid email.\nname: This field is required."), + (400, #"{"custom_answers":{"voltage":["Enter a number."],"name":"Required."}}"#, "custom_answers.name: Required.\ncustom_answers.voltage: Enter a number."), (403, #"{"code":"device_superadmin_not_supported"}"#, "Use the web console") ] { let client = service { _ in (status, self.json, Data(body.utf8)) } @@ -194,3 +238,12 @@ func requestBody(_ request: URLRequest) -> Data { } return result } + +private actor NetworkEpochGate { + private var entered = false + private var continuation: CheckedContinuation? + private var observer: CheckedContinuation? + func arrive() async { entered = true; observer?.resume(); observer = nil; await withCheckedContinuation { continuation = $0 } } + func waitUntilEntered() async { if !entered { await withCheckedContinuation { observer = $0 } } } + func release() { continuation?.resume(); continuation = nil } +} diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift new file mode 100644 index 0000000..9543b36 --- /dev/null +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -0,0 +1,97 @@ +import XCTest + +final class MemberActionsFlowTests: XCTestCase { + override func setUp() { continueAfterFailure = false; XCUIDevice.shared.orientation = .portrait } + private func tap(_ element: XCUIElement, in app: XCUIApplication) { + for _ in 0..<10 { if element.exists && element.isHittable { break }; app.swipeUp() } + XCTAssertTrue(element.waitForExistence(timeout: 5)); XCTAssertTrue(element.isHittable); element.tap() + } + private func demo(_ arguments: [String] = []) -> XCUIApplication { + let app = XCUIApplication(); app.launchArguments = ["--ui-testing"] + arguments; app.launch() + tap(app.buttons["explore-demo"], in: app); tap(app.buttons["Continue as member"], in: app) + let username = app.textFields["demo-username"]; tap(username, in: app); username.typeText("meera\n") + app.secureTextFields["demo-password"].typeText("sample\n") + tap(app.buttons["demo-sign-in"], in: app); tap(app.buttons["demo-space-0"], in: app) + XCTAssertTrue(app.staticTexts["Welcome back, Meera Nair."].waitForExistence(timeout: 10)); return app + } + private func tab(_ app: XCUIApplication, _ name: String) { + if app.tabBars.buttons[name].exists { app.tabBars.buttons[name].tap(); return } + let item = app.descendants(matching: .any)["nav-\(name)"].firstMatch + if !item.isHittable { + let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'sidebar'")).firstMatch + if sidebar.exists { sidebar.tap() } + } + tap(item, in: app) + } + private func capture(_ name: String) { let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); attachment.name = name; attachment.lifetime = .keepAlways; add(attachment) } + func testReviewedRequestSubmissionAndExplicitLoanRenewal() { + let app = demo(); tap(app.buttons["Browse inventory"], in: app) + tap(app.descendants(matching: .any)["product-1"].firstMatch, in: app) + tap(app.buttons["Add to request"], in: app); tap(app.buttons["review-request"], in: app) + XCTAssertTrue(app.staticTexts["Review your request"].waitForExistence(timeout: 5)); capture("Reviewed hardware request") + tap(app.buttons["submit-request"], in: app) + XCTAssertTrue(app.staticTexts["Request received"].waitForExistence(timeout: 5)); capture("Server-status request receipt") + tap(app.buttons["Done"], in: app); tab(app, "Activity") + tap(app.buttons["Renew loan"], in: app); tap(app.buttons["Confirm renewal"], in: app) + XCTAssertTrue(app.staticTexts["Loan renewed"].waitForExistence(timeout: 5)); capture("Confirmed loan renewal") + } + func testAvailabilityBookingPendingReceiptAndServerConfirmedCancellation() { + let app = demo(["--demo-action-pending"]); tab(app, "Activity") + tap(app.buttons["Find a space"], in: app) + tap(app.descendants(matching: .any)["space-00000000-0000-0000-0000-000000000031"].firstMatch, in: app) + tap(app.buttons["Check availability"], in: app) + tap(app.buttons["Submit booking request"], in: app) + XCTAssertTrue(app.staticTexts["Booking request received"].waitForExistence(timeout: 5)); capture("Space booking awaiting approval") + tap(app.buttons["View bookings"], in: app) + tap(app.buttons["Workshop bench"], in: app) + tap(app.buttons["Cancel booking"], in: app) + let confirm = app.buttons.matching(identifier: "Cancel booking").allElementsBoundByIndex.first { $0.isHittable } + XCTAssertNotNil(confirm); confirm?.tap() + XCTAssertTrue(app.staticTexts["Cancelled"].waitForExistence(timeout: 5)); capture("Confirmed booking cancellation") + } + func testEventRegistrationRefetchesActualTicketThenClearsQRCodeOnCancel() { + let app = demo(); tap(app.buttons["Make your first circuit"], in: app) + tap(app.buttons["Confirm registration"], in: app) + XCTAssertTrue(app.staticTexts["Registration confirmed"].waitForExistence(timeout: 5)) + tap(app.buttons["View registrations"], in: app) + tap(app.descendants(matching: .any)["registration-777"].firstMatch, in: app) + XCTAssertTrue(app.images["Simulated event ticket QR code"].waitForExistence(timeout: 5)); capture("Confirmed event ticket demo") + tap(app.buttons["Cancel registration"], in: app) + let confirm = app.buttons.matching(identifier: "Cancel registration").allElementsBoundByIndex.first { $0.isHittable } + XCTAssertNotNil(confirm); confirm?.tap() + XCTAssertTrue(app.staticTexts["Cancelled"].waitForExistence(timeout: 5)); XCTAssertFalse(app.images["Simulated event ticket QR code"].exists) + } + func testProfileDraftSurvivesRotationAndOptionalPreferencesCanChange() { + let app = demo(); tab(app, "Profile"); tap(app.buttons["Edit profile"], in: app) + let name = app.textFields["profile-display-name"]; tap(name, in: app) + let old = name.value as? String ?? ""; name.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: old.count) + "Meera Maker\n") + XCUIDevice.shared.orientation = .landscapeLeft + XCTAssertGreaterThan(app.frame.width, app.frame.height); XCTAssertEqual(name.value as? String, "Meera Maker") + tap(app.buttons["Save changes"], in: app); XCTAssertTrue(app.staticTexts["Profile saved."].waitForExistence(timeout: 5)); capture("Native profile saved landscape") + let back = app.navigationBars.buttons["Profile"]; XCTAssertTrue(back.exists); back.tap() + tap(app.buttons["Communication preferences"], in: app) + let email = app.switches["preference-hardware_requests-email"]; tap(email, in: app) + XCTAssertEqual(email.value as? String, "0") + XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'Always-on safety messages'")).firstMatch.exists) + capture("Optional preference and required safety messages") + } + func testWaitlistedRegistrationNeverOffersConfirmedQRCode() { + let app = demo(["--demo-action-waitlisted"]); tap(app.buttons["Make your first circuit"], in: app) + tap(app.buttons["Confirm registration"], in: app); XCTAssertTrue(app.staticTexts["You're on the waitlist"].waitForExistence(timeout: 5)) + tap(app.buttons["View registrations"], in: app); tap(app.descendants(matching: .any)["registration-777"].firstMatch, in: app) + XCTAssertTrue(app.staticTexts["Waitlist position: 3"].waitForExistence(timeout: 5)); XCTAssertFalse(app.images["Simulated event ticket QR code"].exists) + capture("Waitlisted event without a ticket") + } + func testLargestTextDarkRequestReviewPortraitAndLandscape() { + let app = demo(["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]) + tap(app.buttons["appearance"], in: app) + tap(app.buttons["Browse inventory"], in: app) + tap(app.descendants(matching: .any)["product-1"].firstMatch, in: app) + tap(app.buttons["Add to request"], in: app); tap(app.buttons["review-request"], in: app) + tap(app.buttons["submit-request"], in: app) + XCTAssertTrue(app.staticTexts["Request received"].waitForExistence(timeout: 5)); capture("Request receipt largest text dark portrait") + XCUIDevice.shared.orientation = .landscapeLeft; XCTAssertGreaterThan(app.frame.width, app.frame.height) + tap(app.buttons["Done"], in: app); capture("Member item largest text dark landscape") + } + +} diff --git a/api/provenance.json b/api/provenance.json index b72e3e2..86e465e 100644 --- a/api/provenance.json +++ b/api/provenance.json @@ -7,5 +7,5 @@ "paths": 635, "bytes": 4063303, "sha256": "d5ec904eb2a52ca948ee38dae7840c8859a43e034a22f3b8dc478fd8851cbb9b", - "note": "Copied byte-for-byte; no backend source accessed. Operations include the API prefix, so clients use a validated deployment origin. A0 generates health and device-refresh operations plus their dependent models." + "note": "Copied byte-for-byte; no backend source accessed. Operations already include the API prefix, so clients use a validated deployment origin. The generator filter selects implemented operations and their generated dependencies. The mechanical build projection selects documented JSON request media or an optional no-body request where malformed content declares no media type; paths, DTO fields and response schemas are preserved." } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f03d6bd..93dc2f4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,6 +1,47 @@ # Phase notes -## A2 Member read flows — implementation checkpoint (2026-10-16) +## A3 Member actions — implementation checkpoint (2026-10-16) + +Add thirteen generated operations for tenant bootstrap, reviewed hardware requests, loan renewal, +space availability/booking/cancellation, event registration/cancellation/ticket QR, account contact +profile and communication preferences. Hardware retries keep an idempotency key for the reviewed +content; editing creates a new attempt. Booking and event receipts preserve server status only, +then refetch Activity for actual booking references and registration IDs. Native QR rendering accepts +only bounded monochrome geometry, never browser content; refusal, cancellation and account scope +invalidation clear ticket content. + +Profile edits retain drafts on refusal, require explicit save/discard and survive adaptive layout +reconstruction. Actual bootstrap modules/features gate self-service, preferences and digest controls; +mandatory safety messages remain on while optional feature/channel updates can be disabled. Phone +tabs and tablet sidebar use shared per-destination paths. Product, profile, booking and event drafts +belong to one account/makerspace and are invalidated before changing context. Read and action +Retry-After deadlines survive retries, filters, form reopening and foreground refresh. +Authenticated requests retain an atomic credential/installation epoch: delayed 401s and queued +makerspace operations cannot replay an old account's mutation under a newly installed session, +even when both accounts select the same makerspace. Rotation within one session still coalesces. + +Debug actions mutate a separate in-memory store shared with the read repository. The UI can preview +success/refusal/conflict/rate-limit, booking approval and event waitlist outcomes, and never creates +real loans, bookings, tickets or SDK charges. Activate the official pinned Stripe/Razorpay SDK +adapters now for early native compilation; payment list/intent/polling UI is still the A5 checkpoint. +The immutable API contract remains unchanged. The mechanical build projection selects documented +JSON formats and omits malformed optional request-body media declarations for empty POSTs only; +all paths, response schemas and generated model fields stay intact. + +The owner's mobile-repository directive moves the mobile UX spec and motion contract to +`docs/mobile-auth-ux-spec.md` and `tokens/mobile-motion.json`. Restore Design to its original +`fd28f825086a2cac7fa297881f1860b9d332db69` brand baseline; new mobile UX work stays in this repo. +Historical UX evidence below still identifies the exact earlier validated commit. + +Parity: Review your request / Submit request / Request received; Renew this loan? / Confirm renewal / +Loan renewed; Find a space / Check availability / Submit booking request; Booking request received / +Space booked; Register for this event / Confirm registration; Registration confirmed / Awaiting +approval / You're on the waitlist; View bookings / View registrations; Cancel booking / Keep booking; +Cancel registration / Keep registration; Edit profile / Profile saved.; Communication preferences. +Local generation, immutable contract comparison and source checks pass. Native compiler, URLProtocol, +state, SDK callback and phone/tablet action journey results are pending the A3 CI checkpoint. + +## A2 Member read flows — complete (2026-10-16) Add generated inventory/category/detail, activity, typed bookings, events/spaces, membership card and inbox operations. Scope every repository request to its selected makerspace before transport and @@ -27,8 +68,7 @@ privacy window above presented sheets before app-switcher snapshots, preserving on return, with lifecycle tests. The member portrait journeys now pass; keep sidebar visibility tied to the current content width so rotation restores the wide sidebar. The shared main journey waits for layout hit testing before -using the system sidebar toggle. All 73 native cases, including snapshot privacy, pass on tablet; -this focused adaptive regression awaits the follow-up CI run. +using the system sidebar toggle. The focused adaptive regression passes on both phone and tablet, including all snapshot privacy cases. The owner requested mobile UX work stay in this repository. Copy the auth UX spec and motion contract into `docs/mobile-auth-ux-spec.md` and `tokens/mobile-motion.json`; update generation and restore Design's existing `fd28f825086a2cac7fa297881f1860b9d332db69` brand baseline. @@ -40,8 +80,11 @@ Parity: Welcome back, {name}.; Active loans / Upcoming bookings / Events / Membe Search inventory / All categories / Borrowing terms; Requests / Loans / Bookings / Events; All / Unread / Mark all as read; Nothing here yet.; No items match these filters / Clear filters; No dues outstanding.; Dues are unavailable. Try again later. Local generation and source checks pass. -Native generated-model, context-race, card-refusal, transfer/photo and member journey tests await -the A2 macOS CI checkpoint before this phase is complete. +Validation: [CI 38945606385](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/38945606385) +passes 73 native tests and 12 UI journeys on each phone and tablet, 14 Python checks, and unsigned +physical-device Release compilation at `0405b95b84f6773832415553867acfafd1694f41`. Rendered iPad portrait, +landscape, Home, inventory/detail and accessibility sign-in screenshots were reviewed. Production +backend and physical camera/file flows remain deferred integration checks. ## UX/onboarding — complete (2026-10-16) diff --git a/project.yml b/project.yml index cab55b4..772d762 100644 --- a/project.yml +++ b/project.yml @@ -16,6 +16,12 @@ settings: ENABLE_USER_SCRIPT_SANDBOXING: NO TARGETED_DEVICE_FAMILY: '1,2' packages: + Stripe: + url: https://github.com/stripe/stripe-ios.git + exactVersion: 26.14.0 + Razorpay: + url: https://github.com/razorpay/razorpay-pod.git + exactVersion: 1.5.8 GoogleSignIn: url: https://github.com/google/GoogleSignIn-iOS.git exactVersion: 10.0.0 @@ -32,6 +38,12 @@ targets: - package: SpaceWorksAPI - package: GoogleSignIn product: GoogleSignIn + - package: Stripe + product: StripePaymentSheet + - package: Stripe + product: StripePayments + - package: Razorpay + product: RazorpayCheckout settings: configs: Debug: diff --git a/scripts/generate.py b/scripts/generate.py index 9599553..3673af8 100644 --- a/scripts/generate.py +++ b/scripts/generate.py @@ -146,6 +146,10 @@ def generate(raster=True): content = operation.get("requestBody", {}).get("content", {}) if "application/json" in content: operation["requestBody"]["content"] = {"application/json": content["application/json"]} + elif content and not any("/" in media for media in content) and not operation["requestBody"].get("required", False): + # A few optional empty POST bodies use schema keys instead of media types. + # Select their documented no-body form; preserve the immutable source and every DTO. + del operation["requestBody"] write(ROOT / "Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi.json", json.dumps(document, ensure_ascii=False, indent=2) + "\n") diff --git a/scripts/tests/test_generation.py b/scripts/tests/test_generation.py index 8e98080..75bd03e 100644 --- a/scripts/tests/test_generation.py +++ b/scripts/tests/test_generation.py @@ -25,7 +25,9 @@ def test_authoritative_contract_is_unchanged_and_staged_for_plugin(self): content = operation.get("requestBody", {}).get("content", {}) if "application/json" in content: operation["requestBody"]["content"] = {"application/json": content["application/json"]} - self.assertEqual(staged, expected, "Only documented JSON request representations may be selected; all models remain unchanged") + elif content and not any("/" in media for media in content) and not operation["requestBody"].get("required", False): + del operation["requestBody"] + self.assertEqual(staged, expected, "Only documented JSON or optional no-body representations may be selected; all models remain unchanged") self.assertEqual(len(json.loads(source)["paths"]), 635) def test_brand_config_drives_project_display_name_bundle_and_icon(self): From 33536e7ae679f1bd5db6136db2d82c4f095647b5 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:30:44 +0530 Subject: [PATCH 17/98] fix(a3): select Xcode 26 SDK for native payments Co-Authored-By: Codex --- .github/workflows/ios.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 4e5b2db..af52fbd 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -11,6 +11,8 @@ concurrency: jobs: build-and-test: runs-on: macos-15 + env: + DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer timeout-minutes: 60 name: Build and test (${{ matrix.device }}) strategy: @@ -22,6 +24,11 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive + - name: Verify pinned Xcode and SDK + run: | + xcodebuild -version + xcrun --sdk iphonesimulator --show-sdk-version + xcrun --sdk iphoneos --show-sdk-version - name: Install project and icon generators run: brew install xcodegen librsvg - name: Cache Swift package sources From 7adc0571fcc0bca0e0c50501949ee6487d43caab Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:13:54 +0530 Subject: [PATCH 18/98] fix(a3): preserve latest card authority and target visible controls Keep card refresh generations authoritative while retaining Retry-After guards. Dismiss profile keyboards explicitly and make phone/tablet journeys scroll their actual viewport in either direction, including clipped large-type fields. Cache compiled dependencies without omitting any validation. Co-Authored-By: Codex --- .github/workflows/ios.yml | 29 +++++++++++++++++++ SpaceWorks/MemberSettingsViews.swift | 3 ++ SpaceWorks/MemberState.swift | 4 ++- .../MemberActionsFlowTests.swift | 29 +++++++++++++++---- 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index af52fbd..f3852ce 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -37,6 +37,26 @@ jobs: path: SourcePackages key: ${{ runner.os }}-${{ runner.arch }}-spm-${{ hashFiles('project.yml', 'Packages/**/Package.swift') }} restore-keys: ${{ runner.os }}-${{ runner.arch }}-spm- + - name: Identify Xcode build cache + id: xcode-cache + shell: bash + run: | + XCODE_BUILD=$(xcodebuild -version | tr '\n' '-' | tr -cd '[:alnum:].-') + SIM_SDK_BUILD=$(xcrun --sdk iphonesimulator --show-sdk-build-version) + DEVICE_SDK_BUILD=$(xcrun --sdk iphoneos --show-sdk-build-version) + echo "identity=$XCODE_BUILD-$SIM_SDK_BUILD-$DEVICE_SDK_BUILD" >> "$GITHUB_OUTPUT" + - name: Cache compiled dependencies and build intermediates + id: compiled-cache + uses: actions/cache/restore@v4 + with: + path: | + DerivedData/Build + DerivedData/ModuleCache.noindex + DerivedData/SDKStatCaches.noindex + key: ios-build-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.xcode-cache.outputs.identity }}-${{ hashFiles('project.yml', 'Packages/**/Package.swift') }}-${{ matrix.device }}-${{ github.sha }} + restore-keys: | + ios-build-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.xcode-cache.outputs.identity }}-${{ hashFiles('project.yml', 'Packages/**/Package.swift') }}-${{ matrix.device }}- + ios-build-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.xcode-cache.outputs.identity }}-${{ hashFiles('project.yml', 'Packages/**/Package.swift') }}- - name: Generate and validate source inputs run: | python3 scripts/generate.py @@ -75,3 +95,12 @@ jobs: DerivedData/Build/Intermediates.noindex/BuildToolPluginIntermediates/**/GeneratedSources/*.swift SpaceWorks.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved retention-days: 7 + - name: Save compiler cache after validation + if: always() && !cancelled() && steps.compiled-cache.outputs.cache-primary-key != '' && steps.compiled-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + DerivedData/Build + DerivedData/ModuleCache.noindex + DerivedData/SDKStatCaches.noindex + key: ${{ steps.compiled-cache.outputs.cache-primary-key }} diff --git a/SpaceWorks/MemberSettingsViews.swift b/SpaceWorks/MemberSettingsViews.swift index de8899a..85de433 100644 --- a/SpaceWorks/MemberSettingsViews.swift +++ b/SpaceWorks/MemberSettingsViews.swift @@ -6,6 +6,7 @@ struct ProfileEditorView: View { let saved: () async -> Void @State private var discardConfirmation = false @State private var wantsExit = false + @FocusState private var focusedField: String? @Environment(\.dismiss) private var dismiss init(repository: any MemberActionsRepository, isDemo: Bool, drafts: MemberActionDrafts? = nil, saved: @escaping () async -> Void = {}) { _state = State(initialValue: drafts?.profile ?? ProfileEditorState(repository: repository)); self.isDemo = isDemo; self.saved = saved @@ -36,6 +37,7 @@ struct ProfileEditorView: View { }.background(Color.swBg).navigationTitle("Profile details").scrollDismissesKeyboard(.interactively) .navigationBarBackButtonHidden(state.dirty || state.busy).interactiveDismissDisabled(state.dirty || state.busy) .toolbar { + ToolbarItemGroup(placement: .keyboard) { Spacer(); Button("Done") { focusedField = nil } } if state.dirty || state.busy { ToolbarItem(placement: .topBarLeading) { Button("Back") { wantsExit = true; discardConfirmation = true }.frame(minHeight: 48).disabled(state.busy) } } } .task { if !state.dirty { await state.load() } } @@ -48,6 +50,7 @@ struct ProfileEditorView: View { VStack(alignment: .leading, spacing: 8) { Text(label).font(.headline) TextField(label, text: value).textContentType(content).autocorrectionDisabled() + .focused($focusedField, equals: identifier).submitLabel(.done).onSubmit { focusedField = nil } .keyboardType(content == .telephoneNumber ? .phonePad : .default) .padding(14).background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)).accessibilityIdentifier(identifier) diff --git a/SpaceWorks/MemberState.swift b/SpaceWorks/MemberState.swift index 5798c3c..1323ebe 100644 --- a/SpaceWorks/MemberState.swift +++ b/SpaceWorks/MemberState.swift @@ -168,7 +168,9 @@ final class MemberState { catch { guard active else { return }; recordFailure(error, role: "spaces"); spacesLoad = .failed(APIError.display(error)) } } func loadCard() async { - guard canLoad("card"), cardLoad != .loading else { return } + // A newer explicit refresh must supersede an in-flight card read: a refused or + // revoked QR must never be replaced by an older successful response. + guard canLoad("card") else { return } cardGeneration += 1; let generation = cardGeneration card = nil; cardLoad = .loading do { let result = try await repository.card(); guard active, generation == cardGeneration else { return }; card = result; cardLoad = .ready } diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index 9543b36..9c1d4b3 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -3,13 +3,31 @@ import XCTest final class MemberActionsFlowTests: XCTestCase { override func setUp() { continueAfterFailure = false; XCUIDevice.shared.orientation = .portrait } private func tap(_ element: XCUIElement, in app: XCUIApplication) { - for _ in 0..<10 { if element.exists && element.isHittable { break }; app.swipeUp() } - XCTAssertTrue(element.waitForExistence(timeout: 5)); XCTAssertTrue(element.isHittable); element.tap() + for _ in 0..<12 { + if element.exists, element.isHittable { + let viewport = app.scrollViews.firstMatch + let frame = element.frame + let visible = viewport.exists ? frame.intersection(viewport.frame).intersection(app.frame) : frame.intersection(app.frame) + // A large-type field can be partially clipped yet reported hittable. Its + // geometric centre is outside the scroll viewport; tap the observed visible part. + if !visible.isNull, visible.width > 4, visible.height > 4 { + element.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: visible.midX - frame.minX, dy: visible.midY - frame.minY)).tap() + } else { element.tap() } + return + } + let scroll = app.scrollViews.firstMatch + if element.exists, scroll.exists, element.frame.midY < scroll.frame.minY { scroll.swipeDown() } + else if scroll.exists { scroll.swipeUp() } + else { app.swipeUp() } + } + capture("Unreachable action " + element.identifier) + XCTFail("Action must be reachable: \(element.debugDescription)") } private func demo(_ arguments: [String] = []) -> XCUIApplication { let app = XCUIApplication(); app.launchArguments = ["--ui-testing"] + arguments; app.launch() tap(app.buttons["explore-demo"], in: app); tap(app.buttons["Continue as member"], in: app) - let username = app.textFields["demo-username"]; tap(username, in: app); username.typeText("meera\n") + let username = app.textFields["demo-username"]; tap(username, in: app) + XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5)); username.typeText("meera\n") app.secureTextFields["demo-password"].typeText("sample\n") tap(app.buttons["demo-sign-in"], in: app); tap(app.buttons["demo-space-0"], in: app) XCTAssertTrue(app.staticTexts["Welcome back, Meera Nair."].waitForExistence(timeout: 10)); return app @@ -17,7 +35,8 @@ final class MemberActionsFlowTests: XCTestCase { private func tab(_ app: XCUIApplication, _ name: String) { if app.tabBars.buttons[name].exists { app.tabBars.buttons[name].tap(); return } let item = app.descendants(matching: .any)["nav-\(name)"].firstMatch - if !item.isHittable { + let ready = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == true AND isHittable == true"), object: item) + if XCTWaiter.wait(for: [ready], timeout: 3) != .completed { let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'sidebar'")).firstMatch if sidebar.exists { sidebar.tap() } } @@ -68,7 +87,7 @@ final class MemberActionsFlowTests: XCTestCase { XCUIDevice.shared.orientation = .landscapeLeft XCTAssertGreaterThan(app.frame.width, app.frame.height); XCTAssertEqual(name.value as? String, "Meera Maker") tap(app.buttons["Save changes"], in: app); XCTAssertTrue(app.staticTexts["Profile saved."].waitForExistence(timeout: 5)); capture("Native profile saved landscape") - let back = app.navigationBars.buttons["Profile"]; XCTAssertTrue(back.exists); back.tap() + let back = app.navigationBars.buttons["Profile"]; XCTAssertTrue(back.waitForExistence(timeout: 5)); back.tap() tap(app.buttons["Communication preferences"], in: app) let email = app.switches["preference-hardware_requests-email"]; tap(email, in: app) XCTAssertEqual(email.value as? String, "0") From cfd701a0b3576beae7103753cf628be8a82f69f6 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:53:47 +0530 Subject: [PATCH 19/98] test(a3): keep native interactions inside visible content Co-Authored-By: Codex --- .../MemberActionsFlowTests.swift | 65 ++++++++++++++----- 1 file changed, 48 insertions(+), 17 deletions(-) diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index 9c1d4b3..f66af35 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -3,25 +3,54 @@ import XCTest final class MemberActionsFlowTests: XCTestCase { override func setUp() { continueAfterFailure = false; XCUIDevice.shared.orientation = .portrait } private func tap(_ element: XCUIElement, in app: XCUIApplication) { - for _ in 0..<12 { - if element.exists, element.isHittable { - let viewport = app.scrollViews.firstMatch + for _ in 0..<20 { + let scroll = app.scrollViews.allElementsBoundByIndex.first(where: { $0.isHittable }) ?? app.scrollViews.firstMatch + if element.exists { + let insideScroll = scroll.exists && !element.identifier.isEmpty && scroll.descendants(matching: .any).matching(identifier: element.identifier).firstMatch.exists + if element.isHittable && !insideScroll { element.tap(); return } let frame = element.frame - let visible = viewport.exists ? frame.intersection(viewport.frame).intersection(app.frame) : frame.intersection(app.frame) - // A large-type field can be partially clipped yet reported hittable. Its - // geometric centre is outside the scroll viewport; tap the observed visible part. - if !visible.isNull, visible.width > 4, visible.height > 4 { - element.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: visible.midX - frame.minX, dy: visible.midY - frame.minY)).tap() - } else { element.tap() } - return - } - let scroll = app.scrollViews.firstMatch - if element.exists, scroll.exists, element.frame.midY < scroll.frame.minY { scroll.swipeDown() } - else if scroll.exists { scroll.swipeUp() } - else { app.swipeUp() } + let viewport = visibleContent(scroll, app) + let visible = frame.intersection(viewport) + if element.isHittable, !visible.isNull, visible.width > 4, visible.height > 4, + viewport.insetBy(dx: 2, dy: 2).contains(CGPoint(x: frame.midX, y: frame.midY)) { + // Use the native activation point. A wide Toggle's label centre is not + // its switch thumb; coordinates are only needed for clipped text fields. + element.tap(); return + } + if (element.elementType == .textField || element.elementType == .secureTextField), + element.isHittable, !visible.isNull, visible.width > 8, visible.height > 8 { + element.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: visible.midX - frame.minX, dy: visible.midY - frame.minY)).tap(); return + } + drag(scroll, app, down: frame.midY < viewport.midY) + } else { drag(scroll, app, down: false) } } capture("Unreachable action " + element.identifier) - XCTFail("Action must be reachable: \(element.debugDescription)") + XCTFail("Action must be reachable: \(element.identifier)") + } + private func visibleContent(_ scroll: XCUIElement, _ app: XCUIApplication) -> CGRect { + var viewport = scroll.exists ? scroll.frame.intersection(app.frame) : app.frame + // SwiftUI's accessibility ScrollView frame includes content UNDER native bars. + // A target can be marked hittable there while its coordinate activates another tab. + for bar in app.navigationBars.allElementsBoundByIndex where bar.exists && bar.isHittable { + let frame = bar.frame + if frame.intersects(viewport), frame.midY < viewport.midY { + let bottom = viewport.maxY; viewport.origin.y = max(viewport.minY, frame.maxY); viewport.size.height = max(0, bottom - viewport.minY) + } + } + for bar in app.tabBars.allElementsBoundByIndex + app.keyboards.allElementsBoundByIndex where bar.exists { + let frame = bar.frame + if frame.intersects(viewport), frame.midY > viewport.minY { viewport.size.height = max(0, min(viewport.maxY, frame.minY) - viewport.minY) } + } + return viewport.insetBy(dx: 8, dy: 8) + } + private func drag(_ scroll: XCUIElement, _ app: XCUIApplication, down: Bool) { + let viewport = visibleContent(scroll, app) + guard viewport.width > 20, viewport.height > 30 else { return } + // Drag entirely inside the actual visible content, never across tab/navigation bars. + let start = CGPoint(x: viewport.midX, y: viewport.minY + viewport.height * (down ? 0.3 : 0.7)) + let end = CGPoint(x: viewport.midX, y: viewport.minY + viewport.height * (down ? 0.7 : 0.3)) + app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: start.x - app.frame.minX, dy: start.y - app.frame.minY)) + .press(forDuration: 0.05, thenDragTo: app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: end.x - app.frame.minX, dy: end.y - app.frame.minY))) } private func demo(_ arguments: [String] = []) -> XCUIApplication { let app = XCUIApplication(); app.launchArguments = ["--ui-testing"] + arguments; app.launch() @@ -90,7 +119,9 @@ final class MemberActionsFlowTests: XCTestCase { let back = app.navigationBars.buttons["Profile"]; XCTAssertTrue(back.waitForExistence(timeout: 5)); back.tap() tap(app.buttons["Communication preferences"], in: app) let email = app.switches["preference-hardware_requests-email"]; tap(email, in: app) - XCTAssertEqual(email.value as? String, "0") + // The displayed value changes only after the repository confirms the update. + let confirmedOff = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == %@", "0"), object: email) + XCTAssertEqual(XCTWaiter.wait(for: [confirmedOff], timeout: 5), .completed) XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'Always-on safety messages'")).firstMatch.exists) capture("Optional preference and required safety messages") } From 9b304d298162927ccce56a0c9b98b550f2d705e9 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:32:10 +0530 Subject: [PATCH 20/98] test(a3): handle unpublished offscreen control geometry Co-Authored-By: Codex --- SpaceWorksUITests/MemberActionsFlowTests.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index f66af35..a368c18 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -21,7 +21,11 @@ final class MemberActionsFlowTests: XCTestCase { element.isHittable, !visible.isNull, visible.width > 8, visible.height > 8 { element.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: visible.midX - frame.minX, dy: visible.midY - frame.minY)).tap(); return } - drag(scroll, app, down: frame.midY < viewport.midY) + // An off-screen SwiftUI switch can exist with an empty accessibility + // rect. Its zero midpoint is not evidence that the control is ABOVE us. + // Search down the content until UIKit publishes usable visible geometry. + let meaningfulFrame = !frame.isEmpty && frame.minX.isFinite && frame.minY.isFinite && frame.maxX.isFinite && frame.maxY.isFinite + drag(scroll, app, down: meaningfulFrame && frame.intersects(app.frame) && frame.midY < viewport.midY) } else { drag(scroll, app, down: false) } } capture("Unreachable action " + element.identifier) From d1648e5c251ed6581bf792bd26004fb33b9890e6 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:11:55 +0530 Subject: [PATCH 21/98] test(a3): search scroll content without midpoint oscillation Co-Authored-By: Codex --- .../MemberActionsFlowTests.swift | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index a368c18..178a5a2 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -3,14 +3,19 @@ import XCTest final class MemberActionsFlowTests: XCTestCase { override func setUp() { continueAfterFailure = false; XCUIDevice.shared.orientation = .portrait } private func tap(_ element: XCUIElement, in app: XCUIApplication) { - for _ in 0..<20 { - let scroll = app.scrollViews.allElementsBoundByIndex.first(where: { $0.isHittable }) ?? app.scrollViews.firstMatch + for attempt in 0..<24 { + let scroll = containingScroll(element, app) if element.exists { - let insideScroll = scroll.exists && !element.identifier.isEmpty && scroll.descendants(matching: .any).matching(identifier: element.identifier).firstMatch.exists + let insideScroll = contains(element, scroll) if element.isHittable && !insideScroll { element.tap(); return } let frame = element.frame let viewport = visibleContent(scroll, app) let visible = frame.intersection(viewport) + print("Native action \(element.identifier.isEmpty ? element.label : element.identifier), attempt \(attempt), frame \(frame), viewport \(viewport), hittable \(element.isHittable)") + // A SwiftUI Toggle can expose a label rectangle that differs from its + // native UISwitch activation point. Let XCTest activate a visible switch. + if element.elementType == .switch, element.isHittable, + !visible.isNull, visible.width > 4, visible.height > 4 { element.tap(); return } if element.isHittable, !visible.isNull, visible.width > 4, visible.height > 4, viewport.insetBy(dx: 2, dy: 2).contains(CGPoint(x: frame.midX, y: frame.midY)) { // Use the native activation point. A wide Toggle's label centre is not @@ -21,16 +26,28 @@ final class MemberActionsFlowTests: XCTestCase { element.isHittable, !visible.isNull, visible.width > 8, visible.height > 8 { element.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: visible.midX - frame.minX, dy: visible.midY - frame.minY)).tap(); return } - // An off-screen SwiftUI switch can exist with an empty accessibility - // rect. Its zero midpoint is not evidence that the control is ABOVE us. - // Search down the content until UIKit publishes usable visible geometry. - let meaningfulFrame = !frame.isEmpty && frame.minX.isFinite && frame.minY.isFinite && frame.maxX.isFinite && frame.maxY.isFinite - drag(scroll, app, down: meaningfulFrame && frame.intersects(app.frame) && frame.midY < viewport.midY) - } else { drag(scroll, app, down: false) } + // Off-screen accessibility frames can jump across the viewport after + // rotation. Reversing on their midpoint oscillated around hidden controls. + // Sweep consistently in each direction; activation still requires a real + // visible, hittable target. New screens normally start near their top. + drag(scroll, app, down: attempt >= 12) + } else { drag(scroll, app, down: attempt >= 12) } } capture("Unreachable action " + element.identifier) XCTFail("Action must be reachable: \(element.identifier)") } + private func contains(_ element: XCUIElement, _ scroll: XCUIElement) -> Bool { + guard element.exists, scroll.exists else { return false } + let descendants = scroll.descendants(matching: element.elementType) + if !element.identifier.isEmpty { return descendants.matching(identifier: element.identifier).firstMatch.exists } + guard !element.label.isEmpty else { return false } + return descendants.matching(NSPredicate(format: "label == %@", element.label)).firstMatch.exists + } + private func containingScroll(_ element: XCUIElement, _ app: XCUIApplication) -> XCUIElement { + let scrolls = app.scrollViews.allElementsBoundByIndex + return scrolls.first(where: { contains(element, $0) }) + ?? scrolls.first(where: { $0.isHittable }) ?? app.scrollViews.firstMatch + } private func visibleContent(_ scroll: XCUIElement, _ app: XCUIApplication) -> CGRect { var viewport = scroll.exists ? scroll.frame.intersection(app.frame) : app.frame // SwiftUI's accessibility ScrollView frame includes content UNDER native bars. @@ -51,8 +68,8 @@ final class MemberActionsFlowTests: XCTestCase { let viewport = visibleContent(scroll, app) guard viewport.width > 20, viewport.height > 30 else { return } // Drag entirely inside the actual visible content, never across tab/navigation bars. - let start = CGPoint(x: viewport.midX, y: viewport.minY + viewport.height * (down ? 0.3 : 0.7)) - let end = CGPoint(x: viewport.midX, y: viewport.minY + viewport.height * (down ? 0.7 : 0.3)) + let start = CGPoint(x: viewport.midX, y: viewport.minY + viewport.height * (down ? 0.15 : 0.85)) + let end = CGPoint(x: viewport.midX, y: viewport.minY + viewport.height * (down ? 0.85 : 0.15)) app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: start.x - app.frame.minX, dy: start.y - app.frame.minY)) .press(forDuration: 0.05, thenDragTo: app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: end.x - app.frame.minX, dy: end.y - app.frame.minY))) } From e427626340eb629d51b3815aeea1b6a0568a0533 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:42:21 +0530 Subject: [PATCH 22/98] test(a3): target review sheets and stop scroll inertia Co-Authored-By: Codex --- SpaceWorks/MemberActionViews.swift | 2 +- .../MemberActionsFlowTests.swift | 33 ++++++++++++++----- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/SpaceWorks/MemberActionViews.swift b/SpaceWorks/MemberActionViews.swift index 26288c1..d968482 100644 --- a/SpaceWorks/MemberActionViews.swift +++ b/SpaceWorks/MemberActionViews.swift @@ -49,7 +49,7 @@ struct RequestCartView: View { } } }.padding(24).frame(maxWidth: 720, alignment: .leading).frame(maxWidth: .infinity).disabled(state.busy) - }.background(Color.swBg).scrollDismissesKeyboard(.interactively) + }.background(Color.swBg).scrollDismissesKeyboard(.interactively).accessibilityIdentifier("request-review-scroll") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close") { dismiss() }.frame(minHeight: 48).disabled(state.busy) } } }.interactiveDismissDisabled(state.busy) } diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index 178a5a2..2654048 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -33,8 +33,9 @@ final class MemberActionsFlowTests: XCTestCase { drag(scroll, app, down: attempt >= 12) } else { drag(scroll, app, down: attempt >= 12) } } - capture("Unreachable action " + element.identifier) - XCTFail("Action must be reachable: \(element.identifier)") + let target = element.exists ? (element.identifier.isEmpty ? element.label : element.identifier) : "missing control" + capture("Unreachable action " + target) + XCTFail("Action must be reachable: \(target)") } private func contains(_ element: XCUIElement, _ scroll: XCUIElement) -> Bool { guard element.exists, scroll.exists else { return false } @@ -44,10 +45,15 @@ final class MemberActionsFlowTests: XCTestCase { return descendants.matching(NSPredicate(format: "label == %@", element.label)).firstMatch.exists } private func containingScroll(_ element: XCUIElement, _ app: XCUIApplication) -> XCUIElement { - let scrolls = app.scrollViews.allElementsBoundByIndex - return scrolls.first(where: { contains(element, $0) }) - ?? scrolls.first(where: { $0.isHittable }) ?? app.scrollViews.firstMatch + let scrolls = app.scrollViews.allElementsBoundByIndex.filter { $0.exists && $0.isHittable } + // Presented SwiftUI sheets may appear in a backing view's AX descendants. + // Its scroll cannot move the modal content. Prefer the actual review container. + if let review = scrolls.first(where: { $0.identifier == "request-review-scroll" }) { return review } + let containing = scrolls.filter { contains(element, $0) } + return containing.min(by: { area($0.frame.intersection(app.frame)) < area($1.frame.intersection(app.frame)) }) + ?? scrolls.first ?? app.scrollViews.firstMatch } + private func area(_ rect: CGRect) -> CGFloat { rect.isNull ? .greatestFiniteMagnitude : rect.width * rect.height } private func visibleContent(_ scroll: XCUIElement, _ app: XCUIApplication) -> CGRect { var viewport = scroll.exists ? scroll.frame.intersection(app.frame) : app.frame // SwiftUI's accessibility ScrollView frame includes content UNDER native bars. @@ -58,7 +64,10 @@ final class MemberActionsFlowTests: XCTestCase { let bottom = viewport.maxY; viewport.origin.y = max(viewport.minY, frame.maxY); viewport.size.height = max(0, bottom - viewport.minY) } } - for bar in app.tabBars.allElementsBoundByIndex + app.keyboards.allElementsBoundByIndex where bar.exists { + // A tab bar hidden behind a modal is not part of the modal viewport. + let lowerBars = app.tabBars.allElementsBoundByIndex.filter { $0.exists && $0.isHittable } + + app.keyboards.allElementsBoundByIndex.filter { $0.exists } + for bar in lowerBars { let frame = bar.frame if frame.intersects(viewport), frame.midY > viewport.minY { viewport.size.height = max(0, min(viewport.maxY, frame.minY) - viewport.minY) } } @@ -68,10 +77,16 @@ final class MemberActionsFlowTests: XCTestCase { let viewport = visibleContent(scroll, app) guard viewport.width > 20, viewport.height > 30 else { return } // Drag entirely inside the actual visible content, never across tab/navigation bars. - let start = CGPoint(x: viewport.midX, y: viewport.minY + viewport.height * (down ? 0.15 : 0.85)) - let end = CGPoint(x: viewport.midX, y: viewport.minY + viewport.height * (down ? 0.85 : 0.15)) + // A fast released drag flung landscape content ~300pt across a 139pt + // viewport. Hold at a slow, bounded endpoint so the next visible target is + // examined before momentum can carry it under the navigation/tab bars. + let distance = min(200, viewport.height * 0.4) + let start = CGPoint(x: viewport.midX, y: viewport.midY + (down ? -distance : distance) / 2) + let end = CGPoint(x: viewport.midX, y: viewport.midY + (down ? distance : -distance) / 2) app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: start.x - app.frame.minX, dy: start.y - app.frame.minY)) - .press(forDuration: 0.05, thenDragTo: app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: end.x - app.frame.minX, dy: end.y - app.frame.minY))) + .press(forDuration: 0.05, + thenDragTo: app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: end.x - app.frame.minX, dy: end.y - app.frame.minY)), + withVelocity: .slow, thenHoldForDuration: 0.3) } private func demo(_ arguments: [String] = []) -> XCUIApplication { let app = XCUIApplication(); app.launchArguments = ["--ui-testing"] + arguments; app.launch() From ab3a43dd3a7d0faf1b8ba8759fc4e0c2244fefde Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:23:00 +0530 Subject: [PATCH 23/98] feat(a4): add owned push registration and trusted navigation Wire contextual notification setup, installation-bound registration, authenticated hint refetch and fail-closed routing. Preserve local cleanup and Retry-After boundaries across account changes. Co-Authored-By: Codex --- .github/workflows/ios.yml | 17 +- .../Sources/SpaceWorksAPI/Networking.swift | 32 +- .../SpaceWorksAPI/PushOperations.swift | 24 ++ .../openapi-generator-config.yaml | 2 + SpaceWorks/AuthState.swift | 177 +++++++++-- SpaceWorks/AuthView.swift | 35 ++- SpaceWorks/DemoMode.swift | 55 +++- SpaceWorks/DemoNotificationsView.swift | 46 +++ SpaceWorks/MemberViews.swift | 7 +- SpaceWorks/MobileLink.swift | 97 ++++++ SpaceWorks/MobileLinkDestination.swift | 89 ++++++ SpaceWorks/MobileLinkNotice.swift | 24 ++ SpaceWorks/MobileLinkRouter.swift | 89 ++++++ SpaceWorks/NotificationSettingsState.swift | 106 +++++++ SpaceWorks/NotificationSettingsView.swift | 42 +++ SpaceWorks/Platform/NativeNotifications.swift | 12 +- SpaceWorks/PushRegistrationState.swift | 279 ++++++++++++++++++ SpaceWorks/PushRegistrationVault.swift | 72 +++++ SpaceWorks/RootView.swift | 80 ++++- SpaceWorks/SpaceWorksApp.swift | 1 + .../MobileLinkDestinationTests.swift | 51 ++++ SpaceWorksTests/MobileLinkRouterTests.swift | 99 +++++++ SpaceWorksTests/MobileLinkTests.swift | 45 +++ .../NativeNotificationsTests.swift | 11 + SpaceWorksTests/NetworkingTests.swift | 18 ++ SpaceWorksTests/NotificationAuthTests.swift | 127 ++++++++ .../NotificationSettingsTests.swift | 79 +++++ SpaceWorksTests/PushOperationTests.swift | 83 ++++++ SpaceWorksTests/PushRegistrationTests.swift | 270 +++++++++++++++++ SpaceWorksUITests/NativeUIActions.swift | 89 ++++++ .../NotificationsFlowTests.swift | 57 ++++ branding/brand.json | 4 +- docs/CHANGELOG.md | 40 +++ docs/PUSH-NOTIFICATIONS.md | 82 +++++ scripts/generate.py | 14 +- scripts/mobile_capabilities.py | 35 +++ scripts/tests/test_mobile_capabilities.py | 71 +++++ 37 files changed, 2405 insertions(+), 56 deletions(-) create mode 100644 Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PushOperations.swift create mode 100644 SpaceWorks/DemoNotificationsView.swift create mode 100644 SpaceWorks/MobileLink.swift create mode 100644 SpaceWorks/MobileLinkDestination.swift create mode 100644 SpaceWorks/MobileLinkNotice.swift create mode 100644 SpaceWorks/MobileLinkRouter.swift create mode 100644 SpaceWorks/NotificationSettingsState.swift create mode 100644 SpaceWorks/NotificationSettingsView.swift create mode 100644 SpaceWorks/PushRegistrationState.swift create mode 100644 SpaceWorks/PushRegistrationVault.swift create mode 100644 SpaceWorksTests/MobileLinkDestinationTests.swift create mode 100644 SpaceWorksTests/MobileLinkRouterTests.swift create mode 100644 SpaceWorksTests/MobileLinkTests.swift create mode 100644 SpaceWorksTests/NotificationAuthTests.swift create mode 100644 SpaceWorksTests/NotificationSettingsTests.swift create mode 100644 SpaceWorksTests/PushOperationTests.swift create mode 100644 SpaceWorksTests/PushRegistrationTests.swift create mode 100644 SpaceWorksUITests/NativeUIActions.swift create mode 100644 SpaceWorksUITests/NotificationsFlowTests.swift create mode 100644 docs/PUSH-NOTIFICATIONS.md create mode 100644 scripts/mobile_capabilities.py create mode 100644 scripts/tests/test_mobile_capabilities.py diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index f3852ce..4ae086b 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -31,12 +31,15 @@ jobs: xcrun --sdk iphoneos --show-sdk-version - name: Install project and icon generators run: brew install xcodegen librsvg - - name: Cache Swift package sources - uses: actions/cache@v4 + - name: Restore Swift package sources + id: package-cache + uses: actions/cache/restore@v4 with: path: SourcePackages - key: ${{ runner.os }}-${{ runner.arch }}-spm-${{ hashFiles('project.yml', 'Packages/**/Package.swift') }} - restore-keys: ${{ runner.os }}-${{ runner.arch }}-spm- + key: ${{ runner.os }}-${{ runner.arch }}-spm-v2-${{ hashFiles('project.yml', 'Packages/**/Package.swift') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-spm-v2- + ${{ runner.os }}-${{ runner.arch }}-spm- - name: Identify Xcode build cache id: xcode-cache shell: bash @@ -95,6 +98,12 @@ jobs: DerivedData/Build/Intermediates.noindex/BuildToolPluginIntermediates/**/GeneratedSources/*.swift SpaceWorks.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved retention-days: 7 + - name: Save Swift package sources after validation + if: always() && !cancelled() && steps.package-cache.outputs.cache-primary-key != '' && steps.package-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: SourcePackages + key: ${{ steps.package-cache.outputs.cache-primary-key }} - name: Save compiler cache after validation if: always() && !cancelled() && steps.compiled-cache.outputs.cache-primary-key != '' && steps.compiled-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v4 diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift index 6683c19..b9e598f 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift @@ -103,6 +103,7 @@ public actor RefreshCoordinator { } public func current() -> TokenPair? { pair } public func snapshot() -> CredentialSnapshot { CredentialSnapshot(pair: pair, epoch: generation) } + public func isCurrent(_ snapshot: CredentialSnapshot) -> Bool { generation == snapshot.epoch && pair != nil } public func install(_ value: TokenPair?) throws { generation += 1 flight?.cancel() @@ -146,13 +147,16 @@ public actor RefreshCoordinator { public actor MakerspaceContext { private var selected: Int? + private var activeDirectMemberships = Set() public init() {} public func current() -> Int? { selected } + fileprivate func authorizesPush(_ id: Int) -> Bool { activeDirectMemberships.contains(id) } /// A1 supplies active membership IDs from the generated /auth/me response. public func select(_ id: Int?, activeMemberships: Set) throws { guard id == nil || activeMemberships.contains(id!) else { throw APIError(status: 403, code: "inactive_membership", detail: "Select an active makerspace membership.") } + activeDirectMemberships = activeMemberships selected = id } } @@ -183,7 +187,7 @@ private func checked(_ result: (HTTPResponse, HTTPBody?)) async throws -> (HTTPR let fields = fieldMessages.joined(separator: "\n") var retry: Date? if let header = response.headerFields[.retryAfter] { - if let seconds = TimeInterval(header) { retry = Date().addingTimeInterval(max(0, seconds)) } + if let seconds = TimeInterval(header), seconds.isFinite { retry = Date().addingTimeInterval(max(0, seconds)) } else { let format = DateFormatter() format.locale = Locale(identifier: "en_US_POSIX") @@ -192,6 +196,7 @@ private func checked(_ result: (HTTPResponse, HTTPBody?)) async throws -> (HTTPR retry = format.date(from: header) } } + if response.status.code == 429, retry == nil { retry = Date().addingTimeInterval(60) } let fallback: String switch response.status.code { case 400: fallback = "Check the details and try again." @@ -227,6 +232,7 @@ private struct ErrorMiddleware: ClientMiddleware { private enum DeviceRequestScope { @TaskLocal static var makerspace: Int? @TaskLocal static var credentials: CredentialSnapshot? + @TaskLocal static var pushMakerspace: Int? } public struct SessionMiddleware: ClientMiddleware { @@ -243,11 +249,16 @@ public struct SessionMiddleware: ClientMiddleware { request.headerFields[.authorization] = pair.map { "Bearer \($0.access)" } let selected = await makerspace.current() if let expected = DeviceRequestScope.makerspace, expected != selected { throw CancellationError() } - request.headerFields[HTTPField.Name("X-Makerspace-Id")!] = (DeviceRequestScope.makerspace ?? selected).map(String.init) + if let push = DeviceRequestScope.pushMakerspace { + guard ["api_v1_integrations_push_devices_create", "api_v1_integrations_push_devices_destroy"].contains(operationID), + await makerspace.authorizesPush(push) else { throw CancellationError() } + } + request.headerFields[HTTPField.Name("X-Makerspace-Id")!] = (DeviceRequestScope.pushMakerspace ?? DeviceRequestScope.makerspace ?? selected).map(String.init) let result = try await next(request, body, baseURL) - if result.0.status.code == 401, let pair { + if result.0.status.code == 401, pair != nil { let updated = try await refresh.recover(rejected: credentials) if let expected = DeviceRequestScope.makerspace, await makerspace.current() != expected { throw CancellationError() } + if let push = DeviceRequestScope.pushMakerspace, !(await makerspace.authorizesPush(push)) { throw CancellationError() } guard (await refresh.snapshot()).epoch == credentials.epoch else { throw CancellationError() } request.headerFields[.authorization] = "Bearer \(updated.access)" return try await checked(next(request, body, baseURL)) @@ -311,6 +322,21 @@ public final class SpaceWorksService: Sendable { } } + /// Only push-device create/delete may use another freshly authorized direct membership. + /// UI selection stays unchanged; the caller's set cannot create authority absent from /me. + public func withPushMakerspace(_ id: Int, authorizedDirectMembershipIDs: Set, expectedCredentials: CredentialSnapshot? = nil, operation: @Sendable () async throws -> T) async throws -> T { + guard authorizedDirectMembershipIDs.contains(id), await makerspace.authorizesPush(id) else { throw CancellationError() } + let credentials: CredentialSnapshot + if let expectedCredentials { credentials = expectedCredentials } + else { credentials = await refresh.snapshot() } + guard await refresh.isCurrent(credentials) else { throw CancellationError() } + return try await DeviceRequestScope.$credentials.withValue(credentials) { + try await DeviceRequestScope.$pushMakerspace.withValue(id) { + try await DeviceRequestScope.$makerspace.withValue(nil, operation: operation) + } + } + } + public func configuration() async throws -> Components.Schemas.PublicConfig { try await publicClient.api_v1_config_retrieve().ok.body.json } diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PushOperations.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PushOperations.swift new file mode 100644 index 0000000..78b9c5c --- /dev/null +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PushOperations.swift @@ -0,0 +1,24 @@ +import Foundation +import OpenAPIRuntime + +public enum PushOperationFailure { + public static func isCancellation(_ error: Error) -> Bool { + if error is CancellationError || (error as? URLError)?.code == .cancelled { return true } + if let client = error as? ClientError { return isCancellation(client.underlyingError) } + return false + } +} + +extension SpaceWorksService { + public func registerNativePush(token: String, environment: Components.Schemas.EnvironmentEnum) async throws -> Components.Schemas.PushDevice { + let response = try await client.api_v1_integrations_push_devices_create(.init(body: .json(.init(token: token, provider: .apns, environment: environment)))) + switch response { + case .ok(let value): return try value.body.json + case .created(let value): return try value.body.json + default: throw APIError(status: 0, code: "push_registration_unavailable", detail: "Notifications could not be registered. Your inbox is still available.") + } + } + public func unregisterNativePush(deviceID: Int) async throws { + _ = try await client.api_v1_integrations_push_devices_destroy(.init(path: .init(device_id: deviceID))).noContent + } +} diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml index dfc03a4..58ddc58 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml @@ -46,3 +46,5 @@ filter: - api_v1_notifications_preferences_retrieve - api_v1_notifications_preferences_partial_update - api_v1_bootstrap_retrieve + - api_v1_integrations_push_devices_create + - api_v1_integrations_push_devices_destroy diff --git a/SpaceWorks/AuthState.swift b/SpaceWorks/AuthState.swift index 73a2560..cfb6c93 100644 --- a/SpaceWorks/AuthState.swift +++ b/SpaceWorks/AuthState.swift @@ -10,6 +10,7 @@ final class AuthState { var password = "" var code = "" private(set) var busy = false + private(set) var endingSession = false private(set) var error: String? private(set) var retryAfter: Date? private(set) var user: Components.Schemas.AuthUserPayload? @@ -23,14 +24,27 @@ final class AuthState { private let settings: UserDefaults private let storage: any TokenStorage private var restored = false + private var identityGeneration = 0 + private var identityFlight: Task? + private var privateHolders: [UUID: () -> Void] = [:] private var mfaExpires: Date? private let serverScope: String + private let origin: ServerOrigin + private(set) var notifications: NotificationSettingsState? + private(set) var foregroundHintRevision = 0 + private(set) var inboxRefreshRevision = 0 + private(set) var inboxRefreshMakerspaceID: Int? + private var pendingForegroundHint: (accountID: Int, makerspaceID: Int)? + private var pushAccountID: Int? + private let notificationOwnerID = UUID() + private static var notificationOwner: UUID? init(origin: ServerOrigin, session: URLSession = DeploymentSession.make(), settings: UserDefaults = .standard, storage: (any TokenStorage)? = nil, attest: @escaping @Sendable (String) async throws -> Data = { try await NativeAttestor.attest($0) }) { let storage = storage ?? TokenVault(origin: origin) self.storage = storage + self.origin = origin self.attest = attest self.settings = settings self.serverScope = origin.url.absoluteString @@ -87,8 +101,9 @@ final class AuthState { guard await service.refresh.current() != nil else { stage = .signIn; return } try await loadIdentity() } catch { + guard !(error is CancellationError) else { return } self.error = APIError.display(error) - if isUnauthorized(error) { try? await service.refresh.install(nil) } + if isUnauthorized(error) { await invalidateSession(message: APIError.display(error)) } stage = .signIn } } @@ -155,23 +170,33 @@ final class AuthState { } } func foreground() async { - guard stage == .signedIn, !busy else { return } - busy = true - defer { busy = false } - do { try await loadIdentity() } catch { + guard stage == .signedIn, !busy, (retryAfter ?? .distantPast) <= Date() else { return } + let generation = identityGeneration + defer { if let notifications { Task { await notifications.refresh() } } } + do { try await refreshedIdentity() } catch { + guard generation == identityGeneration else { return } fail(error) let hasNoCredentials = await service.refresh.current() == nil - if isUnauthorized(error) || hasNoCredentials { try? await service.refresh.install(nil); await clear() } + if isUnauthorized(error) || hasNoCredentials { await invalidateSession(message: APIError.display(error)) } } } func invalidateSession(message: String) async { + guard !endingSession else { return } + endingSession = true; busy = true; defer { busy = false; endingSession = false } + let cleanup = detachNotificationCleanup() + clearLocalIdentity() + await cleanup.state?.stop(authorizedDirectMembershipIDs: cleanup.ids) try? await service.refresh.install(nil) - await clear(); error = message + try? await service.makerspace.select(nil, activeMemberships: []) + error = message } func signOut() async { - guard !busy else { return } - busy = true; error = nil - defer { busy = false } + guard !busy, !endingSession else { return } + endingSession = true; busy = true; error = nil + defer { busy = false; endingSession = false } + let cleanup = detachNotificationCleanup() + clearLocalIdentity() + await cleanup.state?.stop(authorizedDirectMembershipIDs: cleanup.ids) var revocationFailed = false do { try await service.signOut() } catch { revocationFailed = true } do { @@ -193,7 +218,7 @@ final class AuthState { if stage == .passwordChange { error = "Your account still requires a password update. Try again or contact your makerspace." } } catch { fail(error) - if isUnauthorized(error) { try? await service.refresh.install(nil); await clear() } + if isUnauthorized(error) { await invalidateSession(message: APIError.display(error)) } } } private func accept(_ pair: TokenPair) async throws { @@ -202,30 +227,140 @@ final class AuthState { } try await service.refresh.install(pair) do { try await loadIdentity() } catch { + await stopNotifications() try? await service.refresh.install(nil) throw error } } private func loadIdentity() async throws { + let generation = identityGeneration + let credentials = await service.refresh.snapshot() let identity = try await service.me() + guard generation == identityGeneration, await service.refresh.isCurrent(credentials) else { throw CancellationError() } guard !identity.is_superuser else { - try? await service.refresh.install(nil) - await clear() + await invalidateSession(message: "Use the web console") throw APIError(status: 403, code: "device_superadmin_not_supported", detail: "Use the web console") } - user = identity - let ids = Set(memberships.map(\.id)) - let saved = selectedID ?? settings.object(forKey: selectionKey) as? Int - let selection = saved.flatMap { ids.contains($0) ? $0 : nil } ?? (memberships.count == 1 ? memberships.first?.id : nil) + let direct = identity.makerspaces.filter { $0.source == .membership } + let ids = Set(direct.map(\.id)) + let previous = user?.id == identity.id ? selectedID : nil + let saved = previous ?? settings.object(forKey: "makerspace-selection.\(serverScope).\(identity.id)") as? Int + let selection = saved.flatMap { ids.contains($0) ? $0 : nil } ?? (direct.count == 1 ? direct.first?.id : nil) + if selectedID != selection || user?.id != identity.id { clearPrivateHolders() } try await service.makerspace.select(selection, activeMemberships: ids) - selectedID = selection + guard generation == identityGeneration, await service.refresh.isCurrent(credentials) else { throw CancellationError() } + if let pushAccountID, pushAccountID != identity.id { detachNotifications() } + user = identity; selectedID = selection stage = identity.must_change_password ? .passwordChange : .signedIn + if stage == .signedIn { configureNotifications(accountID: identity.id, credentials: credentials) } + } + /// Push registration, foreground refresh and notification taps share one current identity read. + /// Publication and busy-state release happen inside the shared task before any waiter proceeds. + private func refreshedIdentity() async throws { + if let identityFlight { return try await identityFlight.value } + guard !busy, !endingSession else { throw CancellationError() } + let generation = identityGeneration; busy = true + let task = Task { + do { + try await self.loadIdentity() + guard self.identityGeneration == generation else { throw CancellationError() } + self.identityFlight = nil; self.busy = false + } catch { + if self.identityGeneration == generation { self.identityFlight = nil; self.busy = false } + throw error + } + } + identityFlight = task + try await task.value + } + /// Routing and registration require a successful current /me, never the last cached identity. + func freshMembershipIDs() async throws -> Set { + if let deadline = retryAfter, deadline > Date() { throw APIError(status: 429, code: "identity_throttled", detail: error ?? "Too many requests. Try again later.", retryAfter: deadline) } + guard stage == .signedIn, !endingSession, let accountID = user?.id else { throw CancellationError() } + let generation = identityGeneration + do { + try await refreshedIdentity() + guard generation == identityGeneration, stage == .signedIn, user?.id == accountID else { throw CancellationError() } + return Set(memberships.map(\.id)) + } catch { + guard generation == identityGeneration, user?.id == accountID else { throw CancellationError() } + fail(error) + let noCredentials = await service.refresh.current() == nil + if isUnauthorized(error) || noCredentials { await invalidateSession(message: APIError.display(error)) } + throw error + } + } + func queueForegroundHint(_ hint: NotificationHint) { + guard stage == .signedIn, let accountID = user?.id, selectedID == hint.makerspaceID else { return } + pendingForegroundHint = (accountID, hint.makerspaceID); foregroundHintRevision += 1 + } + func processForegroundHint() async { + guard !busy, let pending = pendingForegroundHint else { return } + pendingForegroundHint = nil + guard stage == .signedIn, user?.id == pending.accountID, selectedID == pending.makerspaceID else { return } + do { + let ids = try await freshMembershipIDs() + guard stage == .signedIn, user?.id == pending.accountID, selectedID == pending.makerspaceID, + ids.contains(pending.makerspaceID) else { return } + inboxRefreshMakerspaceID = pending.makerspaceID; inboxRefreshRevision += 1 + } catch { /* Existing identity error/cooldown is visible. A hint cannot fall back to cached authority. */ } + } + func selectForLink(_ id: Int) async throws { + guard stage == .signedIn, memberships.contains(where: { $0.id == id }) else { throw CancellationError() } + if selectedID != id { clearPrivateHolders() } + try await service.makerspace.select(id, activeMemberships: Set(memberships.map(\.id))) + selectedID = id; settings.set(id, forKey: selectionKey) + } + private func configureNotifications(accountID: Int, credentials: CredentialSnapshot) { + guard pushAccountID == nil, notifications == nil else { return } + pushAccountID = accountID + let registration = PushRegistrationState(repository: LivePushRegistrationRepository(service: service, expectedCredentials: credentials), + storage: PushRegistrationVault(origin: origin, accountID: accountID), configured: Brand.pushConfigured, + onUnauthorized: { [weak self] message in await self?.invalidateSession(message: message) }, + onForbidden: { [weak self] in await self?.foreground() }) + let notificationState = NotificationSettingsState(registration: registration, environment: Brand.pushEnvironment, + memberships: { [weak self] in guard let self, self.user?.id == accountID else { throw CancellationError() }; return try await self.freshMembershipIDs() }) + notifications = notificationState + Self.notificationOwner = notificationOwnerID + NativeNotifications.shared.onTokenChanged = { [weak notificationState] token in Task { await notificationState?.tokenChanged(token) } } + NativeNotifications.shared.onForegroundHint = { [weak self] hint in self?.queueForegroundHint(hint) } + Task { await notificationState.prepare() } + } + private func detachNotifications() { + notifications?.detach(); notifications = nil; pushAccountID = nil; pendingForegroundHint = nil; inboxRefreshMakerspaceID = nil + if Self.notificationOwner == notificationOwnerID { Self.notificationOwner = nil; NativeNotifications.shared.reset() } + } + private func detachNotificationCleanup() -> (state: NotificationSettingsState?, ids: Set) { + let old = notifications; let ids = Set(memberships.map(\.id)) + notifications = nil; pushAccountID = nil; pendingForegroundHint = nil; inboxRefreshMakerspaceID = nil + if Self.notificationOwner == notificationOwnerID { + Self.notificationOwner = nil + // Detach synchronously before a server cleanup can suspend. A later owner keeps its callbacks. + NativeNotifications.shared.reset() + } + return (old, ids) + } + func stopNotifications() async { + let cleanup = detachNotificationCleanup() + await cleanup.state?.stop(authorizedDirectMembershipIDs: cleanup.ids) + } + func registerPrivateHolder(_ owner: UUID, clear: @escaping () -> Void) { privateHolders[owner] = clear } + func unregisterPrivateHolder(_ owner: UUID) { privateHolders.removeValue(forKey: owner) } + private func clearPrivateHolders() { + let holders = privateHolders; privateHolders = [:] + for clear in holders.values { clear() } + } + private func clearLocalIdentity() { + identityGeneration += 1; identityFlight?.cancel(); identityFlight = nil; clearPrivateHolders() + settings.removeObject(forKey: selectionKey) + user = nil; selectedID = nil; mfa = nil; mfaExpires = nil; enrollment = nil; recoveryCodes = [] + username = ""; password = ""; code = ""; stage = .signIn } private func clear() async { + let cleanup = detachNotificationCleanup() + clearLocalIdentity() + await cleanup.state?.stop(authorizedDirectMembershipIDs: cleanup.ids) try? await service.makerspace.select(nil, activeMemberships: []) - settings.removeObject(forKey: selectionKey) - user = nil; selectedID = nil; mfa = nil; enrollment = nil; recoveryCodes = [] - password = ""; code = ""; stage = .signIn } private func fail(_ failure: Error) { guard !(failure is CancellationError) else { return } diff --git a/SpaceWorks/AuthView.swift b/SpaceWorks/AuthView.swift index 2f9b7ef..e488e6b 100644 --- a/SpaceWorks/AuthView.swift +++ b/SpaceWorks/AuthView.swift @@ -5,14 +5,20 @@ import UniformTypeIdentifiers struct AuthRoot: View { let state: AppState + let linkRouter: MobileLinkRouter + let origin: ServerOrigin @State private var auth: AuthState + @State private var routingOwner = UUID() @Environment(\.scenePhase) private var scenePhase - init(state: AppState, origin: ServerOrigin) { - self.state = state + init(state: AppState, origin: ServerOrigin, linkRouter: MobileLinkRouter) { + self.state = state; self.origin = origin; self.linkRouter = linkRouter _auth = State(initialValue: AuthState(origin: origin)) } var body: some View { - Group { + ZStack { + if auth.endingSession { + ProgressView("Signing out…").frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.swBg) + } else { switch auth.stage { case .restoring: ProgressView("Opening your makerspace…").frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.swBg) case .signIn: SignInView(auth: auth, state: state) @@ -20,12 +26,31 @@ struct AuthRoot: View { case .passwordChange: PasswordChangeView(auth: auth) case .signedIn: if auth.selectedID == nil { MakerspacePicker(auth: auth) } - else { AppShell(state: state, auth: auth) } + else { AppShell(state: state, auth: auth, linkRouter: linkRouter) } + } } } - .task { await auth.restore() } + .task { await auth.restore(); bindRouting() } + .onChange(of: auth.stage) { _, _ in bindRouting() } + .onChange(of: auth.user?.id) { _, _ in bindRouting() } + .onChange(of: auth.foregroundHintRevision) { _, _ in Task { await auth.processForegroundHint() } } + .onChange(of: auth.busy) { _, busy in if !busy { Task { await auth.processForegroundHint() } } } + .onDisappear { linkRouter.unbind(owner: routingOwner); Task { await auth.stopNotifications() } } .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await auth.foreground() } } } } + private func bindRouting() { + NativeNotifications.shared.onTap = { hint in linkRouter.receive(hint) } + if auth.stage == .signedIn, let id = auth.user?.id { + linkRouter.bind(account: origin.url.absoluteString + ".account.\(id)", refresh: { try await auth.freshMembershipIDs() }, select: { try await auth.selectForLink($0) }, resolveLegacy: { legacy in + _ = try await auth.freshMembershipIDs() + guard let membership = auth.memberships.first(where: { $0.slug == legacy.slug }), !legacy.staffInbox || auth.staffToolsAvailable else { + throw APIError(status: 403, code: "link_membership_unavailable", detail: "This update isn't available to your account.") + } + return MobileLink(makerspaceID: membership.id, destination: legacy.staffInbox ? .inbox : .home) + }, owner: routingOwner) + Task { await linkRouter.process() } + } else { linkRouter.unbind(owner: routingOwner) } + } } struct PasswordChangeView: View { diff --git a/SpaceWorks/DemoMode.swift b/SpaceWorks/DemoMode.swift index 03e7833..04109c6 100644 --- a/SpaceWorks/DemoMode.swift +++ b/SpaceWorks/DemoMode.swift @@ -20,6 +20,7 @@ final class DemoSession { var error: String? var retryAfter: Date? var selectedSpace = 0 + var notificationState = DemoNotificationState() var actionScenario = DemoMemberActionsRepository.Scenario.success var profileDisplayName: String? var memberScenario = DemoMemberRepository.Scenario.populated @@ -57,7 +58,7 @@ final class DemoSession { code = ""; error = nil; stage = scenario == .enrollment ? .recovery : .makerspace } func acceptDemoProvider() { error = nil; stage = scenario == .enrollment || scenario == .mfa ? .mfa : .makerspace } - func reset() { username = ""; password = ""; code = ""; error = nil; retryAfter = nil; selectedSpace = 0; profileDisplayName = nil; actionScenario = .success; stage = .welcome } + func reset() { notificationState = DemoNotificationState(); username = ""; password = ""; code = ""; error = nil; retryAfter = nil; selectedSpace = 0; profileDisplayName = nil; actionScenario = .success; stage = .welcome } } enum DemoProvider: String, CaseIterable, Identifiable { @@ -68,6 +69,7 @@ enum DemoProvider: String, CaseIterable, Identifiable { struct DemoRoot: View { let exit: () -> Void @State private var session = DemoSession() + @State private var linkRouter = MobileLinkRouter() var body: some View { VStack(spacing: 0) { HStack { @@ -76,10 +78,29 @@ struct DemoRoot: View { Button("Exit demo") { session.reset(); exit() }.frame(minHeight: 48) }.padding(.horizontal, 20).foregroundStyle(Color.swOnEmphasis).background(Color.swEmphasis) if session.stage == .shell { - DemoShell(session: session).id("\(session.role.rawValue).\(session.selectedSpace).\(session.memberScenario.rawValue)") + DemoShell(session: session, linkRouter: linkRouter).id("\(session.role.rawValue).\(session.selectedSpace).\(session.memberScenario.rawValue)") } else { DemoAuthView(session: session) } }.background(Color.swBg) + .safeAreaInset(edge: .bottom) { MobileLinkNotice(router: linkRouter) } + .onChange(of: linkRouter.revision) { _, _ in Task { await linkRouter.process() } } + .onChange(of: session.stage) { _, _ in bindRouting() } + .onDisappear { linkRouter.reset() } + } + private func bindRouting() { + guard session.stage == .shell else { linkRouter.unbind(); return } + linkRouter.bind(account: "demo." + session.role.rawValue, refresh: { + guard session.stage == .shell else { throw CancellationError() } + return [7, 8] + }, select: { id in + guard session.stage == .shell, [7, 8].contains(id) else { throw CancellationError() } + session.selectedSpace = id == 7 ? 0 : 1 + }, resolveLegacy: { legacy in + guard session.stage == .shell, !legacy.staffInbox || session.role == .staff, + let id = ["fab-lab-kochi": 7, "maker-space-bengaluru": 8][legacy.slug] else { throw CancellationError() } + return MobileLink(makerspaceID: id, destination: legacy.staffInbox ? .inbox : .home) + }) + Task { await linkRouter.process() } } } @@ -225,6 +246,7 @@ struct DemoProviderView: View { struct DemoShell: View { let session: DemoSession + let linkRouter: MobileLinkRouter @State private var member: MemberState @Environment(\.horizontalSizeClass) private var sizeClass @State private var navigation = MemberNavigation() @@ -233,8 +255,8 @@ struct DemoShell: View { @State private var capabilities: MemberCapabilities @State private var actions: DemoMemberActionsRepository @State private var columns = NavigationSplitViewVisibility.automatic - init(session: DemoSession) { - self.session = session + init(session: DemoSession, linkRouter: MobileLinkRouter) { + self.session = session; self.linkRouter = linkRouter let store = DemoMemberStore() let actions = DemoMemberActionsRepository(store: store, scenario: session.actionScenario, name: session.name) _actions = State(initialValue: actions) @@ -272,12 +294,17 @@ struct DemoShell: View { columns = width >= 1000 ? .all : .detailOnly } }.task { async let config: Void = capabilities.load(); await member.loadUnreadCount(); await config }.onDisappear { member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate() } + .task(id: linkRouter.approved?.id) { + if let approved = linkRouter.approved, approved.link.makerspaceID == session.selectedSpace + 7 { + await MobileLinkDestination.open(approved, router: linkRouter, member: member, navigation: navigation, drafts: drafts) + } + } .onChange(of: session.actionScenario) { _, scenario in Task { await actions.preview(scenario) } } .onChange(of: sizeClass) { _, value in if value == .compact { navigation.compactInventory() } } } private func destination(_ tab: AppTab) -> some View { NavigationStack(path: navigation.path(tab)) { - DemoDestination(session: session, member: member, tab: tab, actions: actions, cart: cart, navigation: navigation, capabilities: capabilities, drafts: drafts) + DemoDestination(session: session, member: member, tab: tab, actions: actions, cart: cart, navigation: navigation, capabilities: capabilities, drafts: drafts, linkRouter: linkRouter) .navigationDestination(for: MemberRoute.self) { route in MemberRouteView(route: route, member: member, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, isDemo: true) { if let profile = try? await actions.profile() { session.profileDisplayName = profile.display_name } @@ -295,9 +322,15 @@ struct DemoDestination: View { let navigation: MemberNavigation let capabilities: MemberCapabilities let drafts: MemberActionDrafts + let linkRouter: MobileLinkRouter var body: some View { Group { - if tab != .profile { MemberContent(state: member, tab: tab, name: session.name, selectTab: { navigation.selected = $0 }, actions: actions, cart: cart, navigation: navigation, drafts: drafts) } + if tab != .profile { MemberContent(state: member, tab: tab, name: session.name, selectTab: { navigation.selected = $0 }, actions: actions, cart: cart, navigation: navigation, drafts: drafts, openLink: { text in + guard let origin = try? ServerOrigin("https://demo.spaceworks.invalid") else { return } + if let link = MobileLink.inbox(text, origin: origin) { linkRouter.receive(link) } + else if let legacy = MobileLink.legacy(text, origin: origin) { linkRouter.receive(legacy) } + else { linkRouter.refuseURL() } + }) } else { ScrollView { VStack(alignment: .leading, spacing: 24) { Text("Welcome back, \(session.name).").font(.largeTitle.bold()) @@ -308,6 +341,14 @@ struct DemoDestination: View { if let error = capabilities.error { SWError(message: error); Button("Retry account options") { Task { await capabilities.load() } }.frame(minHeight: 48) } if capabilities.selfService { NavigationLink("Edit profile", value: MemberRoute.contact).frame(minHeight: 48) } if capabilities.preferences { NavigationLink("Communication preferences", value: MemberRoute.preferences).frame(minHeight: 48) } + NavigationLink("Notifications") { + DemoNotificationsView(state: session.notificationState, makerspaceID: session.selectedSpace + 7) { link in + let path: String + if case .product(let id) = link.destination { path = "/makerspaces/\(link.makerspaceID)/inventory/\(id)" } + else { path = "/makerspaces/\(link.makerspaceID)/inbox" } + if let hint = NotificationHint(payload: ["v": 2, "makerspace_id": link.makerspaceID, "link": path]) { linkRouter.receive(hint) } + } + }.frame(minHeight: 48).accessibilityIdentifier("notifications-entry") Picker("Action preview result", selection: Binding(get: { session.actionScenario }, set: { session.actionScenario = $0 })) { ForEach(DemoMemberActionsRepository.Scenario.allCases, id: \.self) { Text($0.rawValue) } }.frame(minHeight: 48).accessibilityIdentifier("action-preview-result") @@ -320,7 +361,7 @@ struct DemoDestination: View { .toolbar { ToolbarItem(placement: .topBarTrailing) { Menu { - ForEach(session.spaces.indices, id: \.self) { index in Button(session.spaces[index]) { session.selectedSpace = index } } + ForEach(session.spaces.indices, id: \.self) { index in Button(session.spaces[index]) { linkRouter.cancelIntent(); session.selectedSpace = index } } } label: { Label(session.spaces[session.selectedSpace], systemImage: "building.2") } } } diff --git a/SpaceWorks/DemoNotificationsView.swift b/SpaceWorks/DemoNotificationsView.swift new file mode 100644 index 0000000..480a5ee --- /dev/null +++ b/SpaceWorks/DemoNotificationsView.swift @@ -0,0 +1,46 @@ +#if DEBUG +import SwiftUI +import Observation + +@MainActor @Observable final class DemoNotificationState { + var enabled = false + var permissionDenied = false + var rotations = 0 + var feedback: String? +} + +struct DemoNotificationsView: View { + @Bindable var state: DemoNotificationState + let makerspaceID: Int + let route: (MobileLink) -> Void + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + SWCard { + Label("Notifications", systemImage: "bell.badge").font(.title2.bold()) + Text("Demo only. Changes stay in this preview. No notifications will be sent.").foregroundStyle(Color.swMuted) + Toggle("Preview permission denied", isOn: $state.permissionDenied).frame(minHeight: 48) + if state.permissionDenied { + Text("Notifications are off for this device.").font(.headline) + Button("Open system settings") { state.feedback = "In a connected app, this opens notification settings for your device." }.frame(minHeight: 48) + } else { + Button(state.enabled ? "Turn off demo notifications" : "Enable notifications") { state.enabled.toggle(); state.feedback = state.enabled ? "Demo notifications are enabled." : "Demo notifications are off." } + .buttonStyle(SWPrimary()).accessibilityIdentifier("enable-demo-notifications") + } + if let feedback = state.feedback { Text(feedback).foregroundStyle(Color.swMuted).accessibilityIdentifier("notification-preview-feedback") } + } + SWCard { + Text("Preview an update").font(.title2.bold()) + Text("Open sample updates from your makerspaces. Changes stay in this preview.").foregroundStyle(Color.swMuted) + Button("Open inventory update") { route(.init(makerspaceID: makerspaceID, destination: .product(1))) }.frame(minHeight: 48).accessibilityIdentifier("demo-push-product") + Button("Open update from another makerspace") { route(.init(makerspaceID: makerspaceID == 7 ? 8 : 7, destination: .inbox)) }.frame(minHeight: 48).accessibilityIdentifier("demo-push-other-space") + Button("Preview older notification") { route(.init(makerspaceID: makerspaceID, destination: .inbox)) }.frame(minHeight: 48) + Button("Preview unavailable item") { route(.init(makerspaceID: makerspaceID, destination: .product(99999))) }.frame(minHeight: 48) + Button("Preview unauthorized makerspace") { route(.init(makerspaceID: 999, destination: .inbox)) }.frame(minHeight: 48).accessibilityIdentifier("demo-push-denied") + Button("Preview device refresh") { state.rotations += 1; state.feedback = "Demo notification settings refreshed. No notifications were sent." }.frame(minHeight: 48) + }.disabled(!state.enabled || state.permissionDenied) + }.padding(24).frame(maxWidth: 640).frame(maxWidth: .infinity) + }.background(Color.swBg).navigationTitle("Notifications") + } +} +#endif diff --git a/SpaceWorks/MemberViews.swift b/SpaceWorks/MemberViews.swift index 00a44a2..a32b2af 100644 --- a/SpaceWorks/MemberViews.swift +++ b/SpaceWorks/MemberViews.swift @@ -11,6 +11,7 @@ struct MemberContent: View { var cart: RequestCartState? = nil var navigation: MemberNavigation? = nil var drafts: MemberActionDrafts? = nil + var openLink: ((String) -> Void)? = nil @State private var showCard = false @State private var renewal: Components.Schemas.MemberLoanActivity? @Environment(\.dynamicTypeSize) private var textSize @@ -18,7 +19,7 @@ struct MemberContent: View { Group { switch tab { case .inventory: InventoryList(state: state, cart: cart, navigation: navigation, drafts: drafts) - case .inbox: MemberInbox(state: state) + case .inbox: MemberInbox(state: state, openLink: openLink) default: ScrollView { VStack(alignment: .leading, spacing: 24) { @@ -339,6 +340,7 @@ struct InventoryDetail: View { struct MemberInbox: View { @Bindable var state: MemberState + var openLink: ((String) -> Void)? = nil var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 16) { @@ -352,6 +354,9 @@ struct MemberInbox: View { HStack { Text(message.title).font(.headline); Spacer(); if message.read_at == nil { Label("Unread", systemImage: "circle.fill").font(.caption).foregroundStyle(Color.swAccentInk) } } Text(message.summary).foregroundStyle(Color.swMuted) Text(message.created_at.formatted(date: .abbreviated, time: .shortened)).font(.caption).foregroundStyle(Color.swMuted) + if let openLink, !message.link.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Button("Open update") { openLink(message.link) }.frame(minHeight: 48).accessibilityIdentifier("open-inbox-update-\(message.id)") + } if message.read_at == nil { Button("Mark as read") { Task { await state.markRead(message.id) } }.frame(minHeight: 48).disabled(state.inboxMutating) } } } diff --git a/SpaceWorks/MobileLink.swift b/SpaceWorks/MobileLink.swift new file mode 100644 index 0000000..d2b3119 --- /dev/null +++ b/SpaceWorks/MobileLink.swift @@ -0,0 +1,97 @@ +import Foundation +import SpaceWorksAPI + +/// Local navigation vocabulary, not an API route or authority claim. +struct MobileLink: Equatable, Sendable { + enum Destination: Equatable, Sendable { + case home, inventory, product(Int), activity, inbox, profile, bookings, booking(String), events, event(String), registrations(Int), spaces, space(String), payments, payment(Int) + } + let makerspaceID: Int + let destination: Destination + static func notification(_ hint: NotificationHint) -> MobileLink { + guard hint.version == 2, let relative = hint.relativeLink, let link = path(relative), link.makerspaceID == hint.makerspaceID else { return .init(makerspaceID: hint.makerspaceID, destination: .inbox) } + return link + } + struct Legacy: Equatable { + let slug: String + let staffInbox: Bool + let expectedMakerspaceID: Int? + init(slug: String, staffInbox: Bool, expectedMakerspaceID: Int? = nil) { self.slug = slug; self.staffInbox = staffInbox; self.expectedMakerspaceID = expectedMakerspaceID } + } + static func legacy(_ raw: String, origin: ServerOrigin? = nil) -> Legacy? { + let path: String + if raw.hasPrefix("/") { path = raw } + else { + guard let origin, let url = URL(string: raw), let parts = URLComponents(url: url, resolvingAgainstBaseURL: false), + parts.scheme == origin.url.scheme, parts.host?.lowercased() == origin.url.host?.lowercased(), + (parts.port ?? (parts.scheme == "https" ? 443 : 80)) == (origin.url.port ?? (origin.url.scheme == "https" ? 443 : 80)), parts.user == nil, parts.password == nil, + parts.query == nil, parts.fragment == nil else { return nil } + path = parts.percentEncodedPath + } + guard !path.contains("%"), !path.contains("\\"), !path.contains("//"), !path.contains("?"), !path.contains("#"), + path.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) else { return nil } + let pieces = path.split(separator: "/").map(String.init) + guard pieces.count >= 3, pieces[0] == "m", pieces[1].count <= 200, + pieces[1].range(of: "^[A-Za-z0-9_-]+$", options: .regularExpression) != nil else { return nil } + if pieces.count == 3, pieces[2] == "member" { return Legacy(slug: pieces[1], staffInbox: false) } + if pieces.count == 4, pieces[2] == "admin", pieces[3] == "notifications" { return Legacy(slug: pieces[1], staffInbox: true) } + return nil + } + static func inbox(_ value: String, origin: ServerOrigin) -> MobileLink? { + if value.hasPrefix("/") { return path(value) } + guard let value = URL(string: value) else { return nil } + return url(value, origin: origin) + } + static func url(_ url: URL, origin: ServerOrigin) -> MobileLink? { + guard let parts = URLComponents(url: url, resolvingAgainstBaseURL: false), parts.user == nil, parts.password == nil, + parts.query == nil, parts.fragment == nil, !parts.percentEncodedPath.contains("%") else { return nil } + if parts.scheme?.lowercased() == "spaceworks" { + if parts.host == nil || parts.host == "" { return path(parts.path) } + guard parts.host?.lowercased() == "makerspaces", parts.port == nil else { return nil } + return path("/makerspaces" + parts.path) + } + let selected = origin.url + let scheme = parts.scheme?.lowercased() + guard scheme == selected.scheme?.lowercased(), parts.host?.lowercased() == selected.host?.lowercased(), + (parts.port ?? (scheme == "https" ? 443 : 80)) == (selected.port ?? (selected.scheme == "https" ? 443 : 80)) else { return nil } + return path(parts.path) + } + static func path(_ path: String) -> MobileLink? { + guard path.utf8.count <= 2048, path.hasPrefix("/"), !path.contains("//"), !path.contains("%"), !path.contains("\\"), + !path.contains("?"), !path.contains("#"), path.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) else { return nil } + let pieces = path.split(separator: "/").map(String.init) + guard pieces.count >= 3, pieces[0] == "makerspaces", let makerspace = positive(pieces[1]), !pieces.contains("."), !pieces.contains("..") else { return nil } + var destination: Destination = .inbox + if pieces.count == 3 { + switch pieces[2] { + case "home": destination = .home + case "inventory": destination = .inventory + case "activity": destination = .activity + case "inbox": destination = .inbox + case "profile": destination = .profile + case "bookings": destination = .bookings + case "events": destination = .events + case "spaces": destination = .spaces + case "payments": destination = .payments + default: break + } + } else if pieces.count == 4 { + switch pieces[2] { + case "inventory": if let id = positive(pieces[3]) { destination = .product(id) } + case "registrations": if let id = positive(pieces[3]) { destination = .registrations(id) } + case "payments": if let id = positive(pieces[3]) { destination = .payment(id) } + case "bookings": if let token = uuid(pieces[3]) { destination = .booking(token) } + case "events": if let token = uuid(pieces[3]) { destination = .event(token) } + case "spaces": if let token = uuid(pieces[3]) { destination = .space(token) } + default: break + } + } + return .init(makerspaceID: makerspace, destination: destination) + } + private static func positive(_ value: String) -> Int? { + guard !value.isEmpty, value.count <= 19, value.utf8.allSatisfy({ (48...57).contains($0) }), let id = Int(value), id > 0 else { return nil }; return id + } + private static func uuid(_ value: String) -> String? { + guard value.count == 36, let uuid = UUID(uuidString: value), uuid.uuidString.lowercased() == value.lowercased() else { return nil }; return uuid.uuidString.lowercased() + } +} diff --git a/SpaceWorks/MobileLinkDestination.swift b/SpaceWorks/MobileLinkDestination.swift new file mode 100644 index 0000000..701e46f --- /dev/null +++ b/SpaceWorks/MobileLinkDestination.swift @@ -0,0 +1,89 @@ +import Foundation +import SpaceWorksAPI + +/// A permitted hint still has to resolve against fresh member data before navigation. +@MainActor enum MobileLinkDestination { + static func open(_ value: MobileLinkRouter.Approved, router: MobileLinkRouter, member: MemberState, + navigation: MemberNavigation, drafts: MemberActionDrafts, + payment: ((Int?) async throws -> Void)? = nil) async { + guard router.isCurrent(value) else { return } + do { + let destination = value.link.destination + if value.refetchInbox { + try available(member, "inbox"); member.unreadOnly = false + await member.loadInbox(); try ready(member.inboxLoad, deadline: member.deadline("inbox")) + } + guard router.isCurrent(value) else { return } + switch destination { + case .home, .activity: + try available(member, "activity"); await member.loadActivity(); try ready(member.activityLoad, deadline: member.deadline("activity")) + case .inventory: + try available(member, "inventory"); member.search = ""; member.category = nil + await member.loadInventory(); try ready(member.inventoryLoad, deadline: member.deadline("inventory")) + case .product(let id): + let state = drafts.product(id, repository: member.repository) + guard (state.retryAfter ?? .distantPast) <= Date(), !state.busy else { throw unavailable(deadline: state.retryAfter) } + await state.load() + guard state.product != nil else { throw unavailable(state.error, deadline: state.retryAfter) } + case .inbox: + if !value.refetchInbox { + try available(member, "inbox"); member.unreadOnly = false + await member.loadInbox(); try ready(member.inboxLoad, deadline: member.deadline("inbox")) + } + case .bookings, .booking: + try available(member, "bookings"); await member.loadBookings(); try ready(member.bookingsLoad, deadline: member.deadline("bookings")) + if case .booking(let token) = destination { + var pages = 1 + while !member.bookings.contains(where: { $0.public_token == token }), member.bookingsHasMore, pages < 100 { + guard router.isCurrent(value) else { return } + try available(member, "bookings"); await member.loadBookings(more: true); try ready(member.bookingsLoad, deadline: member.deadline("bookings")); pages += 1 + } + guard member.bookings.contains(where: { $0.public_token == token }) else { throw missing } + } + case .events, .event: + try available(member, "events"); await member.loadEvents(); try ready(member.eventsLoad, deadline: member.deadline("events")) + if case .event(let token) = destination, !member.events.contains(where: { $0.public_token == token }) { throw missing } + case .registrations(let id): + try available(member, "activity"); await member.loadActivity(); try ready(member.activityLoad, deadline: member.deadline("activity")) + guard member.activity?.event_registrations?.contains(where: { $0.registration_id == id }) == true else { throw missing } + case .spaces, .space: + try available(member, "spaces"); await member.loadSpaces(); try ready(member.spacesLoad, deadline: member.deadline("spaces")) + if case .space(let token) = destination, !member.spaces.contains(where: { $0.public_token == token }) { throw missing } + case .payments, .payment: + guard let payment else { throw unavailable("Payments aren't available in this build yet.") } + if case .payment(let id) = destination { try await payment(id) } else { try await payment(nil) } + guard router.isCurrent(value) else { return }; router.finish(value); return + case .profile: break // The fresh /me response authorized this account-only destination. + } + guard router.isCurrent(value) else { return } + switch destination { + case .home: root(.home, navigation) + case .inventory: root(.inventory, navigation) + case .product(let id): navigation.inventorySelection = id; navigation.open(.product(id), tab: .inventory) + case .activity, .bookings, .events: root(.activity, navigation) + case .inbox: root(.inbox, navigation) + case .profile: root(.profile, navigation) + case .booking(let token): navigation.open(.booking(token), tab: .activity) + case .event(let token): navigation.open(.event(token), tab: .activity) + case .registrations(let id): navigation.open(.registration(id), tab: .activity) + case .spaces: navigation.open(.spaces, tab: .activity) + case .space(let token): navigation.open(.space(token), tab: .activity) + case .payments, .payment: break + } + router.finish(value) + } catch { router.destinationFailed(value, error: error) } + } + private static func root(_ tab: AppTab, _ navigation: MemberNavigation) { navigation.path(tab).wrappedValue = []; navigation.selected = tab } + private static var missing: APIError { APIError(status: 404, code: "link_item_unavailable", detail: "This item is no longer available. Open your inbox for the latest update.") } + private static func available(_ member: MemberState, _ role: String) throws { + guard member.canLoad(role) else { throw unavailable(deadline: member.deadline(role)) } + } + private static func ready(_ load: MemberState.Load, deadline: Date?) throws { + if case .ready = load { return } + if case .failed(let message) = load { throw unavailable(message, deadline: deadline) } + throw unavailable("This update is still loading. Try again in a moment.", deadline: deadline) + } + private static func unavailable(_ message: String? = nil, deadline: Date? = nil) -> APIError { + APIError(status: deadline == nil ? 0 : 429, code: "link_unavailable", detail: message ?? "Couldn't open this update. Try again.", retryAfter: deadline) + } +} diff --git a/SpaceWorks/MobileLinkNotice.swift b/SpaceWorks/MobileLinkNotice.swift new file mode 100644 index 0000000..cb02b1f --- /dev/null +++ b/SpaceWorks/MobileLinkNotice.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct MobileLinkNotice: View { + let router: MobileLinkRouter + var body: some View { + if router.busy { + ProgressView("Opening your update…").frame(maxWidth: .infinity, minHeight: 48).background(Color.swPanel) + } else if let error = router.error { + VStack(alignment: .leading, spacing: 8) { + Label(error, systemImage: "exclamationmark.circle").foregroundStyle(Color.swDanger) + .accessibilityIdentifier("mobile-link-error") + HStack { + if router.hasPending { + TimelineView(.periodic(from: .now, by: 1)) { context in + Button("Retry opening update") { Task { await router.process() } }.frame(minHeight: 48) + .disabled((router.retryAfter ?? .distantPast) > context.date) + } + } + Button("Dismiss") { router.cancelIntent() }.frame(minHeight: 48) + } + }.padding(16).frame(maxWidth: .infinity, alignment: .leading).background(Color.swPanel) + } + } +} diff --git a/SpaceWorks/MobileLinkRouter.swift b/SpaceWorks/MobileLinkRouter.swift new file mode 100644 index 0000000..db307c4 --- /dev/null +++ b/SpaceWorks/MobileLinkRouter.swift @@ -0,0 +1,89 @@ +import Foundation +import Observation +import SpaceWorksAPI + +/// A hint cannot navigate until fresh identity and selected scope have both succeeded. +@MainActor @Observable final class MobileLinkRouter { + struct Approved: Identifiable, Equatable { + let id = UUID() + let account: String + let link: MobileLink + let refetchInbox: Bool + init(account: String, link: MobileLink, refetchInbox: Bool = false) { self.account = account; self.link = link; self.refetchInbox = refetchInbox } + } + private(set) var pending: MobileLink? + private(set) var pendingLegacy: MobileLink.Legacy? + var hasPending: Bool { pending != nil || pendingLegacy != nil } + private(set) var approved: Approved? + private(set) var busy = false + private(set) var error: String? + private(set) var retryAfter: Date? + private(set) var revision = 0 + private var pendingNeedsInbox = false + private var bindingRevision = 0 + private var bindingOwner: UUID? + private var account: String? + private var refresh: (() async throws -> Set)? + private var select: ((Int) async throws -> Void)? + private var resolveLegacy: ((MobileLink.Legacy) async throws -> MobileLink)? + func cancelIntent() { pendingNeedsInbox = false; revision += 1; pending = nil; pendingLegacy = nil; approved = nil; error = nil } + func refuseURL() { cancelIntent(); error = "That link can't be opened in this app." } + func receive(_ hint: NotificationHint) { + if hint.version == 2, let path = hint.relativeLink, let legacy = MobileLink.legacy(path) { + receive(MobileLink.Legacy(slug: legacy.slug, staffInbox: legacy.staffInbox, expectedMakerspaceID: hint.makerspaceID)) + } else { receive(MobileLink.notification(hint)) } + pendingNeedsInbox = true + } + func receive(_ link: MobileLink) { pendingNeedsInbox = false; revision += 1; pendingLegacy = nil; pending = link; approved = nil; error = nil } + func receive(_ legacy: MobileLink.Legacy) { pendingNeedsInbox = false; revision += 1; pending = nil; pendingLegacy = legacy; approved = nil; error = nil } + func bind(account: String, refresh: @escaping () async throws -> Set, select: @escaping (Int) async throws -> Void, resolveLegacy: ((MobileLink.Legacy) async throws -> MobileLink)? = nil, owner: UUID? = nil) { + if let previous = self.account, previous != account || bindingOwner != owner { bindingRevision += 1; revision += 1; pending = nil; pendingLegacy = nil; approved = nil; retryAfter = nil } + self.account = account; self.bindingOwner = owner; self.refresh = refresh; self.select = select; self.resolveLegacy = resolveLegacy + } + func process(now: Date = Date()) async { + guard !busy else { return }; busy = true; defer { busy = false } + while hasPending, let account, let refresh, let select { + guard (retryAfter ?? .distantPast) <= now else { return } + let expected = revision; let expectedBinding = bindingRevision; let needsInbox = pendingNeedsInbox; error = nil + do { + let intent: MobileLink + if let legacy = pendingLegacy { + guard let resolveLegacy else { throw APIError(status: 403, code: "link_unavailable", detail: "This update isn't available to your account.") } + let resolved = try await resolveLegacy(legacy) + if let expected = legacy.expectedMakerspaceID, resolved.makerspaceID != expected { + intent = MobileLink(makerspaceID: expected, destination: .inbox) + } else { intent = resolved } + } else if let pending { intent = pending } else { return } + guard self.account == account, bindingRevision == expectedBinding else { continue } + if revision != expected { continue } + let membershipIDs = try await refresh() + guard self.account == account, bindingRevision == expectedBinding else { continue } + if revision != expected { continue } + guard membershipIDs.contains(intent.makerspaceID) else { throw APIError(status: 403, code: "link_membership_unavailable", detail: "This makerspace isn't available to your account.") } + try await select(intent.makerspaceID) + guard self.account == account, bindingRevision == expectedBinding else { continue } + if revision != expected { continue } + pending = nil; pendingLegacy = nil; approved = .init(account: account, link: intent, refetchInbox: needsInbox); pendingNeedsInbox = false; retryAfter = nil + } catch { + guard self.account == account, bindingRevision == expectedBinding else { continue } + if let deadline = APIError.retryDeadline(error) { retryAfter = max(retryAfter ?? .distantPast, deadline) } + if revision != expected { continue } + self.error = APIError.display(error); return + } + } + } + func isCurrent(_ value: Approved) -> Bool { approved?.id == value.id && account == value.account } + func destinationFailed(_ value: Approved, error: Error) { + guard isCurrent(value) else { return } + pendingNeedsInbox = value.refetchInbox; pendingLegacy = nil; pending = value.link; approved = nil; self.error = APIError.display(error) + if let deadline = APIError.retryDeadline(error) { retryAfter = max(retryAfter ?? .distantPast, deadline) } + } + func finish(_ value: Approved) { if isCurrent(value) { approved = nil } } + func unbind(owner: UUID? = nil) { + if let owner, bindingOwner != owner { return } + if account != nil { pending = nil; pendingLegacy = nil; error = nil; retryAfter = nil } + if account != nil { pendingNeedsInbox = false } + bindingRevision += 1; revision += 1; bindingOwner = nil; account = nil; refresh = nil; select = nil; resolveLegacy = nil; approved = nil + } + func reset() { unbind(); pendingNeedsInbox = false; pending = nil; pendingLegacy = nil; error = nil; retryAfter = nil } +} diff --git a/SpaceWorks/NotificationSettingsState.swift b/SpaceWorks/NotificationSettingsState.swift new file mode 100644 index 0000000..f0157ce --- /dev/null +++ b/SpaceWorks/NotificationSettingsState.swift @@ -0,0 +1,106 @@ +import Foundation +import Observation +import SpaceWorksAPI +import UIKit +import UserNotifications + +@MainActor protocol NativePushAccess: AnyObject { + var authorization: UNAuthorizationStatus { get } + var token: String? { get } + var error: String? { get } + func requestPermission() async + func refreshAuthorization() async +} +extension NativeNotifications: NativePushAccess {} + +@MainActor @Observable final class NotificationSettingsState { + let registration: PushRegistrationState + private let native: any NativePushAccess + private let environment: String + private let memberships: () async throws -> Set + private let settings: () -> Void + private var active = true + private var revision = 0 + private var wantsEnable = false + private var pendingToken: String? + private var localError: String? + private var identityRetryAfter = Date.distantPast + var retryAfter: Date { max(registration.retryAfter, identityRetryAfter) } + private(set) var busy = false + var error: String? { localError ?? native.error } + var permissionDenied: Bool { native.authorization == .denied } + init(registration: PushRegistrationState, native: any NativePushAccess = NativeNotifications.shared, + environment: String, memberships: @escaping () async throws -> Set, + settings: @escaping () -> Void = { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } }) { + self.registration = registration; self.native = native; self.environment = environment + self.memberships = memberships; self.settings = settings + } + func openSettings() { guard active, registration.configured, !registration.isDemo else { return }; settings() } + func prepare() async { + guard active, !busy else { return } + await registration.load() + await refresh() + } + func refresh() async { + guard active, !busy, registration.configured, !registration.isDemo, registration.enabled || wantsEnable else { return } + busy = true; let expected = revision; defer { finishBusy() } + await native.refreshAuthorization() + guard active, revision == expected, Date() >= retryAfter else { return } + if permissionDenied { + wantsEnable = false + do { let ids = try await memberships(); guard active, revision == expected else { return }; await registration.pause(authorizedDirectMembershipIDs: ids) } + catch { if active, revision == expected { record(error) } } + } else if allowed, let token = native.token { await reconcile(token, expected: expected) } + } + func enable() async { + guard active, !busy, registration.canRequest, Date() >= retryAfter else { return } + busy = true; let expected = revision; defer { finishBusy() } + wantsEnable = true; localError = nil + await native.requestPermission() + guard active, revision == expected else { return } + guard allowed else { wantsEnable = false; return } + if let token = native.token { await reconcile(token, expected: expected) } + // APNs may supply the token after authorization returns. Only this account's callback may use it. + } + func tokenChanged(_ token: String) async { + guard active, allowed, registration.canRequest, registration.enabled || wantsEnable, Date() >= retryAfter else { return } + if busy { pendingToken = token; return } + pendingToken = nil + busy = true; let expected = revision; defer { finishBusy() } + await reconcile(token, expected: expected) + } + private func finishBusy() { + busy = false + if active, let token = pendingToken { pendingToken = nil; Task { await self.tokenChanged(token) } } + } + private var allowed: Bool { [.authorized, .provisional, .ephemeral].contains(native.authorization) } + private func reconcile(_ token: String, expected: Int) async { + do { + let ids = try await memberships() + guard active, revision == expected else { return } + if wantsEnable { await registration.enable(token: token, environment: environment, authorizedDirectMembershipIDs: ids) } + else { await registration.synchronize(token: token, environment: environment, authorizedDirectMembershipIDs: ids) } + if active, revision == expected, registration.enabled { wantsEnable = false } + } catch { if active, revision == expected { record(error) } } + } + func disable() async { + guard active, !busy, Date() >= retryAfter else { return } + wantsEnable = false; revision += 1; let expected = revision; busy = true; localError = nil; defer { finishBusy() } + do { let ids = try await memberships(); guard active, revision == expected else { return }; await registration.disable(authorizedDirectMembershipIDs: ids) } + catch { if active, revision == expected { record(error) } } + } + private func record(_ error: Error) { + localError = APIError.display(error) + if let deadline = APIError.retryDeadline(error) { identityRetryAfter = max(identityRetryAfter, deadline) } + } + /// An already-replaced account must never use its successor's credentials for cleanup. + /// Keep its origin/account-scoped registration IDs for cleanup when that owner authenticates again. + func detach() { + active = false; revision += 1; wantsEnable = false; pendingToken = nil; localError = nil + registration.invalidate() + } + func stop(authorizedDirectMembershipIDs: Set) async { + active = false; revision += 1; wantsEnable = false; pendingToken = nil; localError = nil + await registration.stop(authorizedDirectMembershipIDs: authorizedDirectMembershipIDs) + } +} diff --git a/SpaceWorks/NotificationSettingsView.swift b/SpaceWorks/NotificationSettingsView.swift new file mode 100644 index 0000000..d031553 --- /dev/null +++ b/SpaceWorks/NotificationSettingsView.swift @@ -0,0 +1,42 @@ +import SwiftUI +import UserNotifications + +/// OS delivery permission and account-level server registration are separate controls. +struct NotificationSettingsView: View { + let state: NotificationSettingsState + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + SWCard { + Label("Notifications", systemImage: "bell.badge").font(.title2.bold()) + Text("Keep up with requests, bookings, and important makerspace updates. Your inbox is always available.").foregroundStyle(Color.swMuted) + if !state.registration.configured { + Text("Notifications are not configured for this build. Your inbox is still available.").foregroundStyle(Color.swMuted) + } else if state.permissionDenied { + Text("Notifications are off for this device.").font(.headline) + Button("Open system settings") { state.openSettings() }.frame(minHeight: 48) + } else { + Text(state.registration.enabled ? "Notifications are enabled for this account." : "Enable notifications when you're ready.").foregroundStyle(Color.swMuted) + TimelineView(.periodic(from: .now, by: 1)) { context in + Button(state.busy ? "Updating…" : state.error != nil || state.registration.error != nil ? "Retry" : state.registration.enabled ? "Refresh" : "Enable notifications") { + Task { if state.registration.enabled { await state.refresh() } else { await state.enable() } } + } + .buttonStyle(SWPrimary()).disabled(state.busy || context.date < state.retryAfter) + .accessibilityIdentifier("enable-notifications") + if context.date < state.retryAfter { Text("Try again after \(state.retryAfter.formatted(date: .omitted, time: .shortened)).").foregroundStyle(Color.swMuted) } + } + } + if state.registration.enabled { + Text("Updates are enabled for \(state.registration.registeredMakerspaces.count) makerspaces.").foregroundStyle(Color.swMuted) + TimelineView(.periodic(from: .now, by: 1)) { context in + Button("Turn off for this account") { Task { await state.disable() } }.frame(minHeight: 48).disabled(state.busy || context.date < state.retryAfter) + } + } + if let error = state.error ?? state.registration.error { SWError(message: error) } + } + SWCard { Text("Choose which updates you receive in Communication preferences. Required safety messages remain available in your inbox.").foregroundStyle(Color.swMuted) } + }.padding(24).frame(maxWidth: 640).frame(maxWidth: .infinity) + }.background(Color.swBg).navigationTitle("Notifications") + .task { await state.prepare() } + } +} diff --git a/SpaceWorks/Platform/NativeNotifications.swift b/SpaceWorks/Platform/NativeNotifications.swift index cf857a5..b2c5f22 100644 --- a/SpaceWorks/Platform/NativeNotifications.swift +++ b/SpaceWorks/Platform/NativeNotifications.swift @@ -51,7 +51,7 @@ final class NativeNotifications { var onTap: ((NotificationHint) -> Void)? { didSet { deliverPendingHint() } } - var mayPresent: ((NotificationHint) -> Bool)? + var onForegroundHint: ((NotificationHint) -> Void)? func requestPermission() async { error = nil @@ -85,6 +85,9 @@ final class NativeNotifications { deliverPendingHint() } + /// Foreground content is never shown from the untrusted APNs body. The owner may refresh its current inbox. + func receivedInForeground(_ hint: NotificationHint) { onForegroundHint?(hint) } + private func deliverPendingHint() { guard let hint = pendingHint, let handler = onTap else { return } pendingHint = nil @@ -100,7 +103,7 @@ final class NativeNotifications { pendingHint = nil onTokenChanged = nil onTap = nil - mayPresent = nil + onForegroundHint = nil } } @@ -131,8 +134,9 @@ final class NativeNotificationDelegate: NSObject, UIApplicationDelegate, UNUserN withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { let hint = NotificationHint(payload: notification.request.content.userInfo) Task { @MainActor in - let allowed = hint.map { NativeNotifications.shared.mayPresent?($0) == true } ?? false - completionHandler(allowed ? [.banner, .sound] : []) + if let hint { NativeNotifications.shared.receivedInForeground(hint) } + // Backend payloads are hints, not displayable or authoritative content. + completionHandler([]) } } } diff --git a/SpaceWorks/PushRegistrationState.swift b/SpaceWorks/PushRegistrationState.swift new file mode 100644 index 0000000..0763756 --- /dev/null +++ b/SpaceWorks/PushRegistrationState.swift @@ -0,0 +1,279 @@ +import Foundation +import Observation +import SpaceWorksAPI + +protocol PushRegistrationRepository: Sendable { + func register(makerspaceID: Int, token: String, environment: String, authorizedDirectMembershipIDs: Set) async throws -> Components.Schemas.PushDevice + func unregister(makerspaceID: Int, deviceID: Int, authorizedDirectMembershipIDs: Set) async throws +} + +struct LivePushRegistrationRepository: PushRegistrationRepository { + let service: SpaceWorksService + let expectedCredentials: CredentialSnapshot + func register(makerspaceID: Int, token: String, environment: String, authorizedDirectMembershipIDs: Set) async throws -> Components.Schemas.PushDevice { + guard let environment = Components.Schemas.EnvironmentEnum(rawValue: environment) else { throw PushRegistrationCoordinator.invalidConfiguration } + return try await service.withPushMakerspace(makerspaceID, authorizedDirectMembershipIDs: authorizedDirectMembershipIDs, expectedCredentials: expectedCredentials) { + try await service.registerNativePush(token: token, environment: environment) + } + } + func unregister(makerspaceID: Int, deviceID: Int, authorizedDirectMembershipIDs: Set) async throws { + do { + try await service.withPushMakerspace(makerspaceID, authorizedDirectMembershipIDs: authorizedDirectMembershipIDs, expectedCredentials: expectedCredentials) { + try await service.unregisterNativePush(deviceID: deviceID) + } + } catch { if APIError.status(error) != 404 { throw error } } + } +} + +/// Explicit serialization spans server suspension. Actor isolation alone would permit interleaving. +actor PushRegistrationCoordinator { + private let repository: any PushRegistrationRepository + private let storage: any PushRegistrationStorage + private var snapshot: PushRegistrationSnapshot? + private var locked = false + private var waiters: [CheckedContinuation] = [] + private var suspendedMakerspaces = Set() + init(repository: any PushRegistrationRepository, storage: any PushRegistrationStorage) { self.repository = repository; self.storage = storage } + private func acquire() async { + if !locked { locked = true; return } + await withCheckedContinuation { waiters.append($0) } + } + private func release() { + if waiters.isEmpty { locked = false } else { waiters.removeFirst().resume() } + } + private func value() throws -> PushRegistrationSnapshot { + if let snapshot { return snapshot } + let loaded = try storage.load(); snapshot = loaded; return loaded + } + private func save(_ value: PushRegistrationSnapshot) throws { + // Retain new IDs in memory even if Keychain is temporarily unavailable: logout can clean them. + snapshot = value + try storage.store(value) + } + func load() async throws -> PushRegistrationSnapshot { + await acquire(); defer { release() } + return try value() + } + func enable(token: String, environment: String, authorizedDirectMembershipIDs: Set) async throws -> PushRegistrationSnapshot { + await acquire(); defer { release() } + guard Self.validToken(token), ["development", "production"].contains(environment), authorizedDirectMembershipIDs.allSatisfy({ $0 > 0 }) else { throw Self.invalidConfiguration } + suspendedMakerspaces.subtract(authorizedDirectMembershipIDs) + var state = try value() + state.enabled = true + try save(state) + return try await reconcileLocked(token: token, environment: environment, authorizedDirectMembershipIDs: authorizedDirectMembershipIDs) + } + func reconcile(token: String, environment: String, authorizedDirectMembershipIDs: Set) async throws -> PushRegistrationSnapshot { + await acquire(); defer { release() } + return try await reconcileLocked(token: token, environment: environment, authorizedDirectMembershipIDs: authorizedDirectMembershipIDs) + } + private func reconcileLocked(token: String, environment: String, authorizedDirectMembershipIDs: Set) async throws -> PushRegistrationSnapshot { + var state = try value() + guard state.enabled else { return state } + guard Self.validToken(token), ["development", "production"].contains(environment), authorizedDirectMembershipIDs.allSatisfy({ $0 > 0 }) else { throw Self.invalidConfiguration } + try Task.checkCancellation() + let digest = PushRegistrationVault.digest(token.lowercased()) + // A removed membership can no longer authorize deletion. Its routing is already refused; + // owner calls removeMembership BEFORE a voluntary leave while that scope is still valid. + state.registrations.removeAll { !authorizedDirectMembershipIDs.contains($0.makerspaceID) } + try save(state) + for maker in authorizedDirectMembershipIDs.subtracting(suspendedMakerspaces).sorted() { + try Task.checkCancellation() + if let old = state.registrations.first(where: { $0.makerspaceID == maker }) { + if old.tokenDigest == digest && old.environment == environment && !old.pendingDeletion { continue } + try await repository.unregister(makerspaceID: maker, deviceID: old.deviceID, authorizedDirectMembershipIDs: authorizedDirectMembershipIDs) + state.registrations.removeAll { $0.makerspaceID == maker } + try save(state) + } + let registered = try await repository.register(makerspaceID: maker, token: token.lowercased(), environment: environment, authorizedDirectMembershipIDs: authorizedDirectMembershipIDs) + guard registered.id > 0, registered.makerspace_id == maker, registered.provider == "apns", registered.environment == environment, registered.active else { + throw APIError(status: 0, code: "invalid_push_registration", detail: "The server could not confirm notification registration. Your inbox is still available.") + } + state.registrations.append(.init(makerspaceID: maker, deviceID: registered.id, tokenDigest: digest, environment: environment)) + do { try save(state) } + catch { + // A newly returned server ID must not disappear just because persistence failed. + // Attempt immediate cleanup; keep the pending ID for a later authorized retry. + let failure = error + if let index = state.registrations.firstIndex(where: { $0.makerspaceID == maker }) { state.registrations[index].pendingDeletion = true } + snapshot = state + do { + try await repository.unregister(makerspaceID: maker, deviceID: registered.id, authorizedDirectMembershipIDs: authorizedDirectMembershipIDs) + state.registrations.removeAll { $0.makerspaceID == maker }; snapshot = state + try? storage.store(state) + } catch { throw Self.preferredFailure(failure, error) } + throw failure + } + } + return state + } + /// Use before membership revocation. The real direct-membership set still has to include it. + func removeMembership(_ makerspaceID: Int, authorizedDirectMembershipIDs: Set) async throws -> PushRegistrationSnapshot { + await acquire(); defer { release() } + var state = try value() + guard authorizedDirectMembershipIDs.contains(makerspaceID) else { throw CancellationError() } + suspendedMakerspaces.insert(makerspaceID) + guard let row = state.registrations.first(where: { $0.makerspaceID == makerspaceID }) else { return state } + if let index = state.registrations.firstIndex(where: { $0.makerspaceID == makerspaceID }) { state.registrations[index].pendingDeletion = true } + try save(state) + try await repository.unregister(makerspaceID: makerspaceID, deviceID: row.deviceID, authorizedDirectMembershipIDs: authorizedDirectMembershipIDs) + state.registrations.removeAll { $0.makerspaceID == makerspaceID } + try save(state) + return state + } + /// Only after a failed leave and a fresh identity response confirms that membership remains. + func resumeMembership(_ makerspaceID: Int, authorizedDirectMembershipIDs: Set) async throws -> PushRegistrationSnapshot { + await acquire(); defer { release() } + guard authorizedDirectMembershipIDs.contains(makerspaceID) else { throw CancellationError() } + suspendedMakerspaces.remove(makerspaceID) + return try value() + } + /// Best effort before token/grant removal. Failed deletes remain pending, never active/deduped. + func stop(authorizedDirectMembershipIDs: Set, preserveConsent: Bool) async throws -> PushRegistrationSnapshot { + await acquire(); defer { release() } + var state = try value() + if !preserveConsent { state.enabled = false } + state.registrations = state.registrations.map { var row = $0; row.pendingDeletion = true; return row } + var firstError: Error? + do { try save(state) } catch { firstError = error } + for row in state.registrations { + if !authorizedDirectMembershipIDs.contains(row.makerspaceID) { + state.registrations.removeAll { $0.makerspaceID == row.makerspaceID } + do { try save(state) } catch { firstError = Self.preferredFailure(firstError, error) } + continue + } + do { + try await repository.unregister(makerspaceID: row.makerspaceID, deviceID: row.deviceID, authorizedDirectMembershipIDs: authorizedDirectMembershipIDs) + state.registrations.removeAll { $0.makerspaceID == row.makerspaceID } + try save(state) + } catch { + firstError = Self.preferredFailure(firstError, error) + if [401, 429].contains(APIError.status(error) ?? 0) || PushOperationFailure.isCancellation(error) { break } + } + } + if let firstError { throw firstError } + return state + } + /// Storage failure must not hide an API deadline or revoked authentication from the owner. + private static func preferredFailure(_ existing: Error?, _ incoming: Error) -> Error { + guard let existing else { return incoming } + func priority(_ error: Error) -> Int { + switch APIError.status(error) { + case 401: return 4 + case 429: return 3 + case 403: return 2 + case .some(let status) where status >= 400: return 1 + default: return 0 + } + } + return priority(incoming) > priority(existing) ? incoming : existing + } + static func validToken(_ token: String) -> Bool { + token.utf8.count >= 20 && token.utf8.count <= 4096 && token.count.isMultiple(of: 2) && token.allSatisfy { "0123456789abcdefABCDEF".contains($0) } + } + static var invalidConfiguration: APIError { APIError(status: 0, code: "push_not_configured", detail: "Notifications are not configured for this build. Your inbox is still available.") } +} + +@MainActor @Observable final class PushRegistrationState { + let configured: Bool + let isDemo: Bool + private let coordinator: PushRegistrationCoordinator + private let onUnauthorized: @Sendable (String) async -> Void + private let onForbidden: @Sendable () async -> Void + private var active = true + private var pausing = false + private var generation = 0 + private var calls = 0 + @ObservationIgnored private var cooldownTask: Task? + private(set) var enabled = false + private(set) var registeredMakerspaces = Set() + private(set) var error: String? + private(set) var retryAfter = Date.distantPast + var busy: Bool { calls > 0 } + var canRequest: Bool { active && !pausing && configured && !isDemo && Date() >= retryAfter } + init(repository: any PushRegistrationRepository, storage: any PushRegistrationStorage, configured: Bool, isDemo: Bool = false, + onUnauthorized: @escaping @Sendable (String) async -> Void = { _ in }, onForbidden: @escaping @Sendable () async -> Void = {}) { + coordinator = PushRegistrationCoordinator(repository: repository, storage: storage) + self.configured = configured; self.isDemo = isDemo; self.onUnauthorized = onUnauthorized; self.onForbidden = onForbidden + } + func load() async { guard active, !isDemo else { return }; await perform { try await self.coordinator.load() } } + func enable(token: String, environment: String, authorizedDirectMembershipIDs: Set) async { + guard active else { return } + guard configured, !isDemo else { error = PushRegistrationCoordinator.invalidConfiguration.detail; return } + guard !pausing, Date() >= retryAfter else { return } + await perform { try await self.coordinator.enable(token: token, environment: environment, authorizedDirectMembershipIDs: authorizedDirectMembershipIDs) } + } + func synchronize(token: String, environment: String, authorizedDirectMembershipIDs: Set) async { + guard canRequest else { return } + await perform { try await self.coordinator.reconcile(token: token, environment: environment, authorizedDirectMembershipIDs: authorizedDirectMembershipIDs) } + } + func disable(authorizedDirectMembershipIDs: Set) async { + guard active, !pausing, !isDemo, Date() >= retryAfter else { return } + await perform { try await self.coordinator.stop(authorizedDirectMembershipIDs: authorizedDirectMembershipIDs, preserveConsent: false) } + } + /// OS permission can be revoked outside the app. Remove server routing while retaining + /// account consent so a later permission restoration can synchronize this same state. + func pause(authorizedDirectMembershipIDs: Set) async { + guard canRequest else { return } + pausing = true + defer { pausing = false } + await perform { try await self.coordinator.stop(authorizedDirectMembershipIDs: authorizedDirectMembershipIDs, preserveConsent: true) } + } + @discardableResult func removeMembership(_ id: Int, authorizedDirectMembershipIDs: Set) async -> Bool { + guard active, !pausing, !isDemo, Date() >= retryAfter, authorizedDirectMembershipIDs.contains(id) else { return false } + return await perform { try await self.coordinator.removeMembership(id, authorizedDirectMembershipIDs: authorizedDirectMembershipIDs) } + } + func resumeMembership(_ id: Int, authorizedDirectMembershipIDs: Set) async { + // This only removes a local suspension after fresh identity confirmation. It sends no + // request; keeping the existing deadline still blocks the next reconcile/API mutation. + guard active, !pausing, !isDemo else { return } + await perform { try await self.coordinator.resumeMembership(id, authorizedDirectMembershipIDs: authorizedDirectMembershipIDs) } + } + /// Await this before wiping credentials; it also waits for any registration currently in flight. + /// Suppress auth callbacks here: the owning auth transition is already ending the session. + func stop(authorizedDirectMembershipIDs: Set) async { + invalidate() // Block new APNs callbacks before waiting for the current transaction. + guard !isDemo else { return } + await perform(notifyAuthorization: false) { try await self.coordinator.stop(authorizedDirectMembershipIDs: authorizedDirectMembershipIDs, preserveConsent: true) } + } + func invalidate() { + active = false; generation += 1; enabled = false; registeredMakerspaces = []; error = nil + cooldownTask?.cancel(); cooldownTask = nil + } + private func publish(_ value: PushRegistrationSnapshot) { + enabled = value.enabled + registeredMakerspaces = Set(value.registrations.filter { !$0.pendingDeletion }.map(\.makerspaceID)) + } + @discardableResult private func perform(notifyAuthorization: Bool = true, _ work: () async throws -> PushRegistrationSnapshot) async -> Bool { + generation += 1; let expected = generation + calls += 1; error = nil + var failure: Error? + var succeeded = false + do { let value = try await work(); if active, expected == generation { publish(value); succeeded = true } } + catch { + failure = error + if active, expected == generation, !PushOperationFailure.isCancellation(error) { + self.error = APIError.display(error) + if let deadline = APIError.retryDeadline(error), deadline > retryAfter { + retryAfter = deadline; cooldownTask?.cancel() + cooldownTask = Task { [weak self] in + do { + while Date() < deadline { try await Task.sleep(nanoseconds: UInt64(min(max(0, deadline.timeIntervalSinceNow), 3_600) * 1_000_000_000)) } + } catch { return } + guard let self, self.retryAfter == deadline, Date() >= deadline else { return } + self.retryAfter = .distantPast + } + } + // Partial registrations/deletions are authoritative even when a later maker fails. + if ![401, 403].contains(APIError.status(error) ?? 0), let saved = try? await coordinator.load(), active, expected == generation { publish(saved) } + } + } + calls -= 1 + // The coordinator's serial lock has been released before a callback can trigger logout. + if notifyAuthorization, active, expected == generation, let failure { + if APIError.status(failure) == 401 { await onUnauthorized(APIError.display(failure)) } + else if APIError.status(failure) == 403 { await onForbidden() } + } + return succeeded && active && expected == generation + } +} diff --git a/SpaceWorks/PushRegistrationVault.swift b/SpaceWorks/PushRegistrationVault.swift new file mode 100644 index 0000000..3925bda --- /dev/null +++ b/SpaceWorks/PushRegistrationVault.swift @@ -0,0 +1,72 @@ +import CryptoKit +import Foundation +import Security +import SpaceWorksAPI + +/// Local Keychain metadata, never an API payload. A raw APNs token is never persisted here. +struct PushStoredRegistration: Codable, Equatable, Sendable { + let makerspaceID: Int + let deviceID: Int + let tokenDigest: String + let environment: String + var pendingDeletion: Bool = false +} +struct PushRegistrationSnapshot: Codable, Equatable, Sendable { + var enabled = false + var registrations: [PushStoredRegistration] = [] +} +protocol PushRegistrationStorage: Sendable { + func load() throws -> PushRegistrationSnapshot + func store(_ snapshot: PushRegistrationSnapshot) throws +} + +struct PushRegistrationVault: PushRegistrationStorage { + private let account: String + private let service: String + init(origin: ServerOrigin, accountID: Int, service: String = (Bundle.main.bundleIdentifier ?? "org.spaceworks.app") + ".push-registrations") { + account = Self.digest(origin.url.absoluteString + ".account.\(accountID)") + self.service = service + } + private var query: [String: Any] { + [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, + kSecAttrAccount as String: account, kSecAttrSynchronizable as String: false] + } + func load() throws -> PushRegistrationSnapshot { + var query = self.query + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return PushRegistrationSnapshot() } + try check(status) + guard let data = result as? Data, data.count <= 1_048_576 else { throw storageError } + let snapshot = try JSONDecoder().decode(PushRegistrationSnapshot.self, from: data) + try validate(snapshot) + return snapshot + } + func store(_ snapshot: PushRegistrationSnapshot) throws { + try validate(snapshot) + let data = try JSONEncoder().encode(snapshot) + guard data.count <= 1_048_576 else { throw storageError } + let changes: [String: Any] = [kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly] + let status = SecItemUpdate(query as CFDictionary, changes as CFDictionary) + if status == errSecItemNotFound { try check(SecItemAdd(query.merging(changes) { _, new in new } as CFDictionary, nil)) } + else { try check(status) } + } + func remove() throws { + let status = SecItemDelete(query as CFDictionary) + if status != errSecItemNotFound { try check(status) } + } + private func validate(_ value: PushRegistrationSnapshot) throws { + var keys = Set() + guard value.registrations.allSatisfy({ row in + row.makerspaceID > 0 && row.deviceID > 0 && keys.insert(row.makerspaceID).inserted && + ["development", "production"].contains(row.environment) && row.tokenDigest.count == 64 && + row.tokenDigest.allSatisfy { "0123456789abcdef".contains($0) } + }) else { throw storageError } + } + private var storageError: APIError { APIError(status: 0, code: "push_storage_unavailable", detail: "Notification settings could not be stored securely. Unlock your device and try again.") } + private func check(_ status: OSStatus) throws { guard status == errSecSuccess else { throw storageError } } + static func digest(_ value: String) -> String { SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() } +} diff --git a/SpaceWorks/RootView.swift b/SpaceWorks/RootView.swift index c554416..d6aec92 100644 --- a/SpaceWorks/RootView.swift +++ b/SpaceWorks/RootView.swift @@ -3,6 +3,8 @@ import SpaceWorksAPI struct RootView: View { @State private var state = AppState() + @State private var linkRouter = MobileLinkRouter() + @State private var pendingURL: URL? @AppStorage("introduction-complete") private var introComplete = false #if DEBUG @State private var exploringDemo = false @@ -33,14 +35,45 @@ struct RootView: View { }.background(Color.swSurface) } .nativePrivacyShield() - .task { await state.restore() } + .safeAreaInset(edge: .bottom) { MobileLinkNotice(router: linkRouter) } + .task { + installTapHandler() + await state.restore() + } + .onChange(of: linkRouter.revision) { _, _ in Task { await linkRouter.process() } } + .onOpenURL { url in + #if DEBUG + guard !exploringDemo else { return } + #endif + if NativeIdentity.handleGoogleCallback(url) || NativePayments.handleReturnURL(url) { return } + if let origin = state.connectedOrigin { open(url, origin: origin) } + else { pendingURL = url } + } + .onChange(of: state.connectedOrigin) { old, origin in + if old != nil { linkRouter.reset(); pendingURL = nil } + if let origin, let pendingURL { self.pendingURL = nil; open(pendingURL, origin: origin) } + installTapHandler() + } + } + private func installTapHandler() { + NativeNotifications.shared.onTap = { hint in + #if DEBUG + guard !exploringDemo else { return } + #endif + linkRouter.receive(hint) + } + } + private func open(_ url: URL, origin: ServerOrigin) { + if let link = MobileLink.url(url, origin: origin) { linkRouter.receive(link) } + else if let legacy = MobileLink.legacy(url.absoluteString, origin: origin) { linkRouter.receive(legacy) } + else { linkRouter.refuseURL() } } private var liveContent: some View { VStack(spacing: 0) { switch state.screen { case .connect: ConnectView(state: state) case .shell: - if let origin = state.connectedOrigin { AuthRoot(state: state, origin: origin) } + if let origin = state.connectedOrigin { AuthRoot(state: state, origin: origin, linkRouter: linkRouter) } } #if DEBUG if state.screen == .connect { @@ -113,9 +146,10 @@ enum AppTab: String, CaseIterable, Identifiable { struct AppShell: View { let state: AppState let auth: AuthState + let linkRouter: MobileLinkRouter var body: some View { if let membership = auth.selectedMembership, let origin = state.connectedOrigin { - MemberWorkspace(state: state, auth: auth, origin: origin, id: membership.id, slug: membership.slug) + MemberWorkspace(state: state, auth: auth, origin: origin, id: membership.id, slug: membership.slug, linkRouter: linkRouter) .id("\(origin.url.absoluteString).\(auth.user?.id ?? 0).\(membership.id)") } } @@ -124,7 +158,10 @@ struct AppShell: View { private struct MemberWorkspace: View { let state: AppState let auth: AuthState + let linkRouter: MobileLinkRouter + let makerspaceID: Int @State private var member: MemberState + @State private var privateHolderOwner = UUID() @Environment(\.horizontalSizeClass) private var sizeClass @Environment(\.scenePhase) private var scenePhase @State private var navigation = MemberNavigation() @@ -133,8 +170,8 @@ private struct MemberWorkspace: View { @State private var capabilities: MemberCapabilities let actions: LiveMemberActionsRepository @State private var columns = NavigationSplitViewVisibility.automatic - init(state: AppState, auth: AuthState, origin: ServerOrigin, id: Int, slug: String) { - self.state = state; self.auth = auth + init(state: AppState, auth: AuthState, origin: ServerOrigin, id: Int, slug: String, linkRouter: MobileLinkRouter) { + self.state = state; self.auth = auth; self.linkRouter = linkRouter; self.makerspaceID = id let actions = LiveMemberActionsRepository(service: auth.service, id: id, slug: slug, onUnauthorized: { [weak auth] message in await auth?.invalidateSession(message: message) }, onForbidden: { [weak auth] in await auth?.foreground() }) @@ -179,15 +216,32 @@ private struct MemberWorkspace: View { }.onChange(of: geometry.size.width, initial: true) { _, width in columns = width >= 1000 ? .all : .detailOnly } - }.task { async let config: Void = capabilities.load(); await member.loadUnreadCount(); await config }.onDisappear { member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate() } + }.task { + auth.registerPrivateHolder(privateHolderOwner) { [member, cart, drafts, capabilities, navigation] in + member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate() + } + async let config: Void = capabilities.load(); await member.loadUnreadCount(); await config + }.onDisappear { + auth.unregisterPrivateHolder(privateHolderOwner) + member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate() + } .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await capabilities.load(); await member.loadHome(); await member.loadUnreadCount() } } } + .task(id: linkRouter.approved?.id) { + if let approved = linkRouter.approved, approved.link.makerspaceID == makerspaceID { + await MobileLinkDestination.open(approved, router: linkRouter, member: member, navigation: navigation, drafts: drafts) + } + } + .onChange(of: auth.inboxRefreshRevision) { _, _ in + guard auth.inboxRefreshMakerspaceID == makerspaceID else { return } + Task { await member.loadInbox(); await member.loadUnreadCount() } + } .onChange(of: sizeClass) { _, value in if value == .compact { navigation.compactInventory() } } } private func destination(_ tab: AppTab) -> some View { NavigationStack(path: navigation.path(tab)) { - MemberScreen(state: state, auth: auth, member: member, tab: tab, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities) + MemberScreen(state: state, auth: auth, member: member, tab: tab, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, linkRouter: linkRouter) .navigationDestination(for: MemberRoute.self) { route in MemberRouteView(route: route, member: member, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, isDemo: false) { await auth.foreground() } } @@ -206,12 +260,18 @@ private struct MemberScreen: View { let drafts: MemberActionDrafts let navigation: MemberNavigation let capabilities: MemberCapabilities + let linkRouter: MobileLinkRouter @State private var accountForm: AccountAccessState? @State private var replayIntro = false @State private var confirmSignOut = false var body: some View { Group { - if tab != .profile { MemberContent(state: member, tab: tab, name: auth.displayName, selectTab: { navigation.selected = $0 }, actions: actions, cart: cart, navigation: navigation, drafts: drafts) } + if tab != .profile { MemberContent(state: member, tab: tab, name: auth.displayName, selectTab: { navigation.selected = $0 }, actions: actions, cart: cart, navigation: navigation, drafts: drafts, openLink: { text in + guard let origin = state.connectedOrigin else { linkRouter.refuseURL(); return } + if let link = MobileLink.inbox(text, origin: origin) { linkRouter.receive(link) } + else if let legacy = MobileLink.legacy(text, origin: origin) { linkRouter.receive(legacy) } + else { linkRouter.refuseURL() } + }) } else { ScrollView { VStack(alignment: .leading, spacing: 24) { SWCard { @@ -225,6 +285,9 @@ private struct MemberScreen: View { if let error = capabilities.error { SWError(message: error); Button("Retry account options") { Task { await capabilities.load() } }.frame(minHeight: 48) } if capabilities.selfService { NavigationLink("Edit profile", value: MemberRoute.contact).frame(minHeight: 48) } if capabilities.preferences { NavigationLink("Communication preferences", value: MemberRoute.preferences).frame(minHeight: 48) } + if let notifications = auth.notifications { + NavigationLink("Notifications") { NotificationSettingsView(state: notifications) }.frame(minHeight: 48).accessibilityIdentifier("notifications-entry") + } Button("Show introduction") { replayIntro = true }.frame(minHeight: 48) Button("Show helpful tips") { member.replayGuidance() }.frame(minHeight: 48) Button("Sign out", role: .destructive) { confirmSignOut = true }.frame(minHeight: 48).disabled(auth.busy) @@ -244,6 +307,7 @@ private struct MemberScreen: View { ForEach(auth.memberships, id: \.id) { membership in Button(membership.slug) { guard auth.selectedID != membership.id else { return } + linkRouter.cancelIntent() member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate() Task { await auth.select(membership.id) } } diff --git a/SpaceWorks/SpaceWorksApp.swift b/SpaceWorks/SpaceWorksApp.swift index 9bba7e4..28419e6 100644 --- a/SpaceWorks/SpaceWorksApp.swift +++ b/SpaceWorks/SpaceWorksApp.swift @@ -3,6 +3,7 @@ import SpaceWorksAPI @main struct SpaceWorksApp: App { + @UIApplicationDelegateAdaptor(NativeNotificationDelegate.self) private var notificationDelegate init() { NativeAppearance.apply() if let saved = Brand.serverURL ?? UserDefaults.standard.string(forKey: "server-origin"), diff --git a/SpaceWorksTests/MobileLinkDestinationTests.swift b/SpaceWorksTests/MobileLinkDestinationTests.swift new file mode 100644 index 0000000..f067123 --- /dev/null +++ b/SpaceWorksTests/MobileLinkDestinationTests.swift @@ -0,0 +1,51 @@ +#if DEBUG +import XCTest +import SpaceWorksAPI +@testable import SpaceWorks + +@MainActor final class MobileLinkDestinationTests: XCTestCase { + private func approved(_ router: MobileLinkRouter, _ destination: MobileLink.Destination) async throws -> MobileLinkRouter.Approved { + router.bind(account: "demo", refresh: { [7] }, select: { _ in }) + router.receive(.init(makerspaceID: 7, destination: destination)); await router.process() + return try XCTUnwrap(router.approved) + } + func testProductLinkRefetchesBeforeOpeningAndFinishesTicket() async throws { + let router = MobileLinkRouter(); let value = try await approved(router, .product(1)) + let member = MemberState(repository: DemoMemberRepository()); let navigation = MemberNavigation() + let drafts = MemberActionDrafts(repository: DemoMemberActionsRepository()) + await MobileLinkDestination.open(value, router: router, member: member, navigation: navigation, drafts: drafts) + XCTAssertEqual(navigation.selected, .inventory); XCTAssertEqual(navigation.paths[.inventory], [.product(1)]) + XCTAssertNotNil(drafts.product(1, repository: member.repository).product); XCTAssertNil(router.approved) + } + func testPushAlwaysRefetchesInboxAndRefusalPreventsItsResourceNavigation() async throws { + let router = MobileLinkRouter() + router.bind(account: "demo", refresh: { [7] }, select: { _ in }) + router.receive(try XCTUnwrap(NotificationHint(payload: ["v": 2, "makerspace_id": 7, "link": "/makerspaces/7/inventory/1"]))) + await router.process(); let value = try XCTUnwrap(router.approved); XCTAssertTrue(value.refetchInbox) + let member = MemberState(repository: DemoMemberRepository(scenario: .offline)); let navigation = MemberNavigation() + let drafts = MemberActionDrafts(repository: DemoMemberActionsRepository()) + await MobileLinkDestination.open(value, router: router, member: member, navigation: navigation, drafts: drafts) + XCTAssertEqual(navigation.selected, .home); XCTAssertNil(drafts.product(1, repository: member.repository).error) + XCTAssertEqual(member.inboxLoad, .failed("Couldn't connect. Try again.")); XCTAssertNotNil(router.error) + } + func testMissingResourceLeavesNavigationUnchangedAndOffersSafeRetry() async throws { + let router = MobileLinkRouter(); let value = try await approved(router, .product(99999)) + let member = MemberState(repository: DemoMemberRepository()); let navigation = MemberNavigation() + await MobileLinkDestination.open(value, router: router, member: member, navigation: navigation, drafts: MemberActionDrafts(repository: DemoMemberActionsRepository())) + XCTAssertEqual(navigation.selected, .home); XCTAssertTrue(navigation.paths.isEmpty) + XCTAssertEqual(router.pending, value.link); XCTAssertEqual(router.error, "This item is no longer available.") + } + func testOfflineInboxDoesNotNavigateUsingCachedAuthorityOrRows() async throws { + let router = MobileLinkRouter(); let value = try await approved(router, .inbox) + let navigation = MemberNavigation() + await MobileLinkDestination.open(value, router: router, member: MemberState(repository: DemoMemberRepository(scenario: .offline)), navigation: navigation, drafts: MemberActionDrafts(repository: DemoMemberActionsRepository())) + XCTAssertEqual(navigation.selected, .home); XCTAssertNotNil(router.error); XCTAssertNil(router.approved) + } + func testFinishedOrLoggedOutTicketCannotNavigateEvenIfFreshResourceExists() async throws { + let router = MobileLinkRouter(); let value = try await approved(router, .inbox); router.unbind() + let member = MemberState(repository: DemoMemberRepository()); let navigation = MemberNavigation() + await MobileLinkDestination.open(value, router: router, member: member, navigation: navigation, drafts: MemberActionDrafts(repository: DemoMemberActionsRepository())) + XCTAssertEqual(member.inboxLoad, .idle); XCTAssertEqual(navigation.selected, .home) + } +} +#endif diff --git a/SpaceWorksTests/MobileLinkRouterTests.swift b/SpaceWorksTests/MobileLinkRouterTests.swift new file mode 100644 index 0000000..6e895eb --- /dev/null +++ b/SpaceWorksTests/MobileLinkRouterTests.swift @@ -0,0 +1,99 @@ +import XCTest +import SpaceWorksAPI +@testable import SpaceWorks + +@MainActor final class MobileLinkRouterTests: XCTestCase { + func testTapWaitsForAuthenticationThenFreshIdentityBeforeSelectingAndApproving() async { + let router = MobileLinkRouter(); let link = MobileLink(makerspaceID: 7, destination: .product(1)) + router.receive(link); await router.process(); XCTAssertNil(router.approved); XCTAssertEqual(router.pending, link) + var steps: [String] = [] + router.bind(account: "server/account1", refresh: { steps.append("me"); return [7] }, select: { id in steps.append("select-\(id)") }) + await router.process() + XCTAssertEqual(steps, ["me", "select-7"]); XCTAssertEqual(router.approved?.link, link); XCTAssertNil(router.pending) + } + func testUnauthorizedMakerspaceNeverChangesScopeOrApproves() async { + let router = MobileLinkRouter(); var selected = false + router.bind(account: "server/account1", refresh: { [7] }, select: { _ in selected = true }) + router.receive(.init(makerspaceID: 99, destination: .inbox)); await router.process() + XCTAssertFalse(selected); XCTAssertNil(router.approved); XCTAssertEqual(router.error, "This makerspace isn't available to your account.") + } + func testFailedSelectionAndRefreshCannotPublishNavigation() async { + let router = MobileLinkRouter() + router.bind(account: "server/account1", refresh: { [7] }, select: { _ in throw APIError(status: 403, code: "denied", detail: "Membership is inactive.") }) + router.receive(.init(makerspaceID: 7, destination: .inbox)); await router.process() + XCTAssertNil(router.approved); XCTAssertEqual(router.error, "Membership is inactive.") + router.bind(account: "server/account1", refresh: { throw URLError(.notConnectedToInternet) }, select: { _ in XCTFail("Offline identity changed scope") }) + await router.process(); XCTAssertNil(router.approved); XCTAssertEqual(router.error, "Couldn't connect. Try again.") + } + func testLogoutDuringFreshIdentityCannotApproveOldTapOrKeepCallbacks() async { + let gate = RoutingGate(); let router = MobileLinkRouter() + router.bind(account: "server/account1", refresh: { await gate.wait(); return [7] }, select: { _ in XCTFail("Logged-out hint selected scope") }) + router.receive(.init(makerspaceID: 7, destination: .inbox)) + let pending = Task { await router.process() }; await gate.requested(); router.unbind(); await gate.resolve(); await pending.value + XCTAssertNil(router.approved); XCTAssertNil(router.pending) + } + func testNewAccountTapProcessesWhenOldAccountLookupFinishes() async { + let gate = RoutingGate(); let router = MobileLinkRouter() + router.bind(account: "old", refresh: { await gate.wait(); return [7] }, select: { _ in XCTFail("Old account selected scope") }) + router.receive(.init(makerspaceID: 7, destination: .home)) + let old = Task { await router.process() }; await gate.requested(); router.unbind() + router.bind(account: "new", refresh: { [8] }, select: { XCTAssertEqual($0, 8) }) + router.receive(.init(makerspaceID: 8, destination: .inbox)); await router.process() + await gate.resolve(); await old.value + XCTAssertEqual(router.approved?.account, "new"); XCTAssertEqual(router.approved?.link.makerspaceID, 8) + } + func testRetryAfterSurvivesAnotherHintAndExpiryAllowsFreshIdentity() async { + let router = MobileLinkRouter(); let now = Date(); let deadline = now.addingTimeInterval(60); var calls = 0 + router.bind(account: "server/account1", refresh: { calls += 1; if calls == 1 { throw APIError(status: 429, code: "throttled", detail: "Try later.", retryAfter: deadline) }; return [7] }, select: { _ in }) + router.receive(.init(makerspaceID: 7, destination: .inbox)); await router.process(now: now) + router.receive(.init(makerspaceID: 7, destination: .home)); await router.process(now: now) + XCTAssertEqual(calls, 1); XCTAssertNil(router.approved) + await router.process(now: deadline.addingTimeInterval(1)); XCTAssertEqual(calls, 2); XCTAssertEqual(router.approved?.link.destination, .home) + } + func testLegacySlugResolvesFromFreshIdentityBeforeSelectedScopeAndNeverGrantsItsOwnAuthority() async throws { + let router = MobileLinkRouter(); var calls: [String] = [] + router.receive(MobileLink.Legacy(slug: "fresh-lab", staffInbox: false)) + await router.process(); XCTAssertNil(router.approved) + router.bind(account: "account", refresh: { calls.append("me"); return [8] }, select: { calls.append("select-\($0)") }, resolveLegacy: { value in + calls.append("resolve-fresh-" + value.slug) + return MobileLink(makerspaceID: 8, destination: .home) + }) + await router.process() + XCTAssertEqual(calls, ["resolve-fresh-fresh-lab", "me", "select-8"]) + XCTAssertEqual(router.approved?.link, MobileLink(makerspaceID: 8, destination: .home)) + router.receive(MobileLink.Legacy(slug: "unknown-lab", staffInbox: false)) + router.bind(account: "account", refresh: { [7] }, select: { _ in XCTFail("Unverified legacy mapping selected scope") }, resolveLegacy: { _ in MobileLink(makerspaceID: 8, destination: .home) }) + await router.process(); XCTAssertNil(router.approved); XCTAssertNotNil(router.error) + } + func testNotificationLegacySlugCannotOverrideItsExplicitMakerspaceHint() async throws { + let router = MobileLinkRouter() + router.bind(account: "account", refresh: { [7, 8] }, select: { XCTAssertEqual($0, 7) }, resolveLegacy: { _ in .init(makerspaceID: 8, destination: .home) }) + router.receive(try XCTUnwrap(NotificationHint(payload: ["v": 2, "makerspace_id": 7, "link": "/m/other-lab/member"]))) + await router.process() + XCTAssertEqual(router.approved?.link, MobileLink(makerspaceID: 7, destination: .inbox)) + } + func testOldViewTeardownCannotUnbindNewAccountRoutingOwner() async { + let router = MobileLinkRouter(); let oldOwner = UUID(); let newOwner = UUID() + router.bind(account: "old", refresh: { [7] }, select: { _ in }, owner: oldOwner) + router.bind(account: "new", refresh: { [8] }, select: { XCTAssertEqual($0, 8) }, owner: newOwner) + router.receive(.init(makerspaceID: 8, destination: .inbox)) + router.unbind(owner: oldOwner); await router.process() + XCTAssertEqual(router.approved?.account, "new"); XCTAssertEqual(router.approved?.link.makerspaceID, 8) + } + func testNewTapWinsOverAnEarlierIdentityLookupAndFinishedTicketCannotNavigateAgain() async throws { + let gate = RoutingGate(); let router = MobileLinkRouter(); var calls = 0 + router.bind(account: "server/account1", refresh: { calls += 1; if calls == 1 { await gate.wait() }; return [7] }, select: { _ in }) + router.receive(.init(makerspaceID: 7, destination: .product(1))) + let first = Task { await router.process() }; await gate.requested() + router.receive(.init(makerspaceID: 7, destination: .inbox)); await gate.resolve(); await first.value + let approved = try XCTUnwrap(router.approved); XCTAssertEqual(approved.link.destination, .inbox); XCTAssertEqual(calls, 2) + XCTAssertTrue(router.isCurrent(approved)); router.finish(approved); XCTAssertFalse(router.isCurrent(approved)) + } +} +@MainActor private final class RoutingGate { + private var continuation: CheckedContinuation? + private var observer: CheckedContinuation? + func wait() async { await withCheckedContinuation { continuation = $0; observer?.resume(); observer = nil } } + func requested() async { if continuation != nil { return }; await withCheckedContinuation { observer = $0 } } + func resolve() { continuation?.resume(); continuation = nil } +} diff --git a/SpaceWorksTests/MobileLinkTests.swift b/SpaceWorksTests/MobileLinkTests.swift new file mode 100644 index 0000000..2744e17 --- /dev/null +++ b/SpaceWorksTests/MobileLinkTests.swift @@ -0,0 +1,45 @@ +import XCTest +import SpaceWorksAPI +@testable import SpaceWorks + +final class MobileLinkTests: XCTestCase { + private let origin = try! ServerOrigin("https://spaceworks.example") + func testAllowlistedPathsUseOnlyActualNumericIDsAndPublicUUIDs() { + let token = "00000000-0000-0000-0000-000000000031" + for (path, result) in [("home", MobileLink.Destination.home), ("inventory/3", .product(3)), ("activity", .activity), ("inbox", .inbox), ("profile", .profile), ("bookings/" + token, .booking(token)), ("events/" + token, .event(token)), ("registrations/777", .registrations(777)), ("spaces/" + token, .space(token)), ("payments/501", .payment(501))] { + XCTAssertEqual(MobileLink.path("/makerspaces/7/" + path), .init(makerspaceID: 7, destination: result)) + } + XCTAssertEqual(MobileLink.path("/makerspaces/7/inventory/not-an-id")?.destination, .inbox) + XCTAssertEqual(MobileLink.path("/makerspaces/7/new-server-feature")?.destination, .inbox) + } + func testUniversalLinksStayOnTheSelectedDeploymentAndDoNotChangeServers() throws { + for value in ["https://spaceworks.example/makerspaces/7/inbox", "https://spaceworks.example:443/makerspaces/7/inbox"] { + XCTAssertEqual(MobileLink.url(try XCTUnwrap(URL(string: value)), origin: origin)?.destination, .inbox) + } + for value in ["https://evil.example/makerspaces/7/inbox", "http://spaceworks.example/makerspaces/7/inbox", "https://user:secret@spaceworks.example/makerspaces/7/inbox", "https://spaceworks.example:8443/makerspaces/7/inbox", "https://spaceworks.example/makerspaces/7/inbox?token=secret", "https://spaceworks.example/makerspaces/7/inbox#script"] { + XCTAssertNil(MobileLink.url(try XCTUnwrap(URL(string: value)), origin: origin)) + } + } + func testCustomSchemeUsesOnlyTheCurrentServerAndCanonicalInAppPath() throws { + for value in ["spaceworks://makerspaces/7/inventory/3", "spaceworks:///makerspaces/7/inventory/3"] { + XCTAssertEqual(MobileLink.url(try XCTUnwrap(URL(string: value)), origin: origin), .init(makerspaceID: 7, destination: .product(3))) + } + for value in ["spaceworks://evil.example/makerspaces/7/home", "spaceworks://user@makerspaces/7/home", "javascript:alert(1)", "file:///makerspaces/7/inbox"] { + XCTAssertNil(MobileLink.url(try XCTUnwrap(URL(string: value)), origin: origin)) + } + } + func testLegacyUnknownVersionsAndScopeConflictingPayloadsFallbackToHintMakerspaceInbox() throws { + let payloads: [[AnyHashable: Any]] = [["makerspace_id": 7], ["makerspace_id": 7, "v": "99", "link": "/makerspaces/7/inventory/3"], ["makerspace_id": 7, "v": "2", "link": "/makerspaces/9/inventory/3"], ["makerspace_id": 7, "v": "2", "link": "https://evil.example/"]] + for payload in payloads { + XCTAssertEqual(MobileLink.notification(try XCTUnwrap(NotificationHint(payload: payload))), .init(makerspaceID: 7, destination: .inbox)) + } + } + func testExistingInboxLandingLinksOnlyParseExactSafeCurrentOriginShapes() { + XCTAssertEqual(MobileLink.legacy("/m/fab-lab-kochi/member", origin: origin), .init(slug: "fab-lab-kochi", staffInbox: false)) + XCTAssertEqual(MobileLink.legacy("https://spaceworks.example:443/m/fab-lab-kochi/admin/notifications", origin: origin), .init(slug: "fab-lab-kochi", staffInbox: true)) + for value in ["https://evil.example/m/fab-lab-kochi/member", "https://user@spaceworks.example/m/fab-lab-kochi/member", "/m/fab-lab-kochi/member?token=secret", "/m/fab-lab-kochi/member/extra", "/m/fab%2dlab/member", "/m/../member", "/m/fab\\lab/member", "//m/fab/member"] { XCTAssertNil(MobileLink.legacy(value, origin: origin)) } + } + func testEncodedTraversalControlCharactersAndInvalidMakerIDsAreRefused() { + for value in ["//evil.example/makerspaces/7/inbox", "/makerspaces/7/../profile", "/makerspaces/7/./inbox", "/makerspaces/7/%2einbox", "/makerspaces/7/inbox\n", "/makerspaces/0/inbox", "/makerspaces/-7/inbox", "/makerspaces/999999999999999999999999999999/inbox", "/makerspaces/7//inbox"] { XCTAssertNil(MobileLink.path(value)) } + } +} diff --git a/SpaceWorksTests/NativeNotificationsTests.swift b/SpaceWorksTests/NativeNotificationsTests.swift index 5ed2c36..b1658d6 100644 --- a/SpaceWorksTests/NativeNotificationsTests.swift +++ b/SpaceWorksTests/NativeNotificationsTests.swift @@ -39,6 +39,17 @@ final class NativeNotificationsTests: XCTestCase { XCTAssertNil(hint?.relativeLink) } + @MainActor func testForegroundReceiptRefreshesWithoutBecomingATapOrRetainingPayload() throws { + let notifications = NativeNotifications() + let hint = try XCTUnwrap(NotificationHint(payload: ["v": "2", "makerspace_id": "7", "link": "/makerspaces/7/inventory/12"])) + var received: [NotificationHint] = []; var taps = 0 + notifications.onForegroundHint = { received.append($0) }; notifications.onTap = { _ in taps += 1 } + notifications.receivedInForeground(hint) + XCTAssertEqual(received, [hint]); XCTAssertEqual(taps, 0); XCTAssertNil(notifications.pendingHint) + notifications.reset(); notifications.receivedInForeground(hint) + XCTAssertEqual(received.count, 1) + } + @MainActor func testColdLaunchHintWaitsForAuthenticatedRouterAndDeliversOnce() throws { let notifications = NativeNotifications() let hint = try XCTUnwrap(NotificationHint(payload: ["makerspace_id": "7"])) diff --git a/SpaceWorksTests/NetworkingTests.swift b/SpaceWorksTests/NetworkingTests.swift index 454ab65..d8114f5 100644 --- a/SpaceWorksTests/NetworkingTests.swift +++ b/SpaceWorksTests/NetworkingTests.swift @@ -162,6 +162,24 @@ final class NetworkingTests: XCTestCase { XCTAssertEqual(counter.value, 1) } + func testMissingInvalidAndNonFiniteRetryAfterUseFiniteConservativeThrottleDeadline() async throws { + for header in [nil, "NaN", "Infinity", "-Infinity", "not a date"] as [String?] { + let before = Date(); let counter = LockedCounter() + let client = service { _ in + counter.increment(); var headers = self.json + if let header { headers["Retry-After"] = header } + return (429, headers, Data(#"{"detail":"Wait before retrying."}"#.utf8)) + } + do { try await client.health(); XCTFail("Expected throttling") } + catch { + let deadline = try XCTUnwrap(APIError.retryDeadline(error)) + XCTAssertTrue(deadline.timeIntervalSinceReferenceDate.isFinite) + XCTAssertGreaterThan(deadline.timeIntervalSince(before), 59) + XCTAssertLessThan(deadline.timeIntervalSinceNow, 61) + } + XCTAssertEqual(counter.value, 1) + } + } func testErrorFallbacksAndSpecialCodes() async throws { for (status, text) in [(400, "Check the details and try again."), (401, "Please sign in again."), (403, "You do not have permission to do this."), (404, "This item wasn't found."), (409, "This changed. Refresh and try again."), (429, "Too many requests. Try again later."), (503, "The server is unavailable. Try again later.")] { let client = service { _ in (status, self.json, Data(#"{"detail":" ","code":"unknown"}"#.utf8)) } diff --git a/SpaceWorksTests/NotificationAuthTests.swift b/SpaceWorksTests/NotificationAuthTests.swift new file mode 100644 index 0000000..300d492 --- /dev/null +++ b/SpaceWorksTests/NotificationAuthTests.swift @@ -0,0 +1,127 @@ +#if DEBUG +import XCTest +import SpaceWorksAPI +@testable import SpaceWorks + +@MainActor final class NotificationAuthTests: XCTestCase { + override func tearDown() { StubProtocol.lock.lock(); StubProtocol.handler = nil; StubProtocol.lock.unlock() } + func testForegroundHintRevalidatesCurrentScopeAndNeverSwitchesForAnotherMakerspace() async throws { + let counter = ForegroundIdentityCounter() + StubProtocol.lock.lock() + StubProtocol.handler = { request in + if request.url?.path == "/api/v1/auth/me" { counter.increment(); return (200, ["Content-Type": "application/json"], Data(DebugAuthFixtures.user.utf8)) } + return (200, ["Content-Type": "application/json"], Data(DebugAuthFixtures.config.utf8)) + }; StubProtocol.lock.unlock() + let configuration = DeviceSession.configuration(); configuration.protocolClasses = [StubProtocol.self] + let state = AuthState(origin: try ServerOrigin("https://auth.example"), session: DeviceSession.make(configuration: configuration), settings: UserDefaults(suiteName: UUID().uuidString)!, storage: NotificationAuthStorage()) + await state.restore(); await state.select(7); let baseline = counter.value + state.queueForegroundHint(try XCTUnwrap(NotificationHint(payload: ["makerspace_id": "8"]))) + await state.processForegroundHint(); XCTAssertEqual(counter.value, baseline); XCTAssertEqual(state.selectedID, 7) + state.queueForegroundHint(try XCTUnwrap(NotificationHint(payload: ["makerspace_id": "7"]))) + await state.processForegroundHint() + XCTAssertEqual(counter.value, baseline + 1); XCTAssertEqual(state.selectedID, 7) + XCTAssertEqual(state.inboxRefreshRevision, 1); XCTAssertEqual(state.inboxRefreshMakerspaceID, 7) + await state.invalidateSession(message: "Signed out.") + } + func testProtectedIdentityAndHoldersClearBeforeDelayedAuthorizedPushCleanupAndTokenWipe() async throws { + let gate = IdentityResponseGate(); defer { gate.release() } + let origin = try ServerOrigin("https://cleanup-" + UUID().uuidString.lowercased() + ".example") + let identity = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(DebugAuthFixtures.user.utf8)) as? [String: Any]) + let accountID = try XCTUnwrap(identity["id"] as? Int) + let vault = PushRegistrationVault(origin: origin, accountID: accountID) + try vault.store(.init(enabled: false, registrations: [.init(makerspaceID: 7, deviceID: 701, tokenDigest: String(repeating: "a", count: 64), environment: "development")])) + defer { try? vault.remove() } + StubProtocol.lock.lock() + StubProtocol.handler = { request in + if request.httpMethod == "DELETE" { + XCTAssertEqual(request.url?.path, "/api/v1/integrations/push/devices/701") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer old") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Makerspace-Id"), "7") + gate.wait(); return (204, [:], Data()) + } + let body = request.url?.path == "/api/v1/auth/me" ? DebugAuthFixtures.user : DebugAuthFixtures.config + return (200, ["Content-Type": "application/json"], Data(body.utf8)) + }; StubProtocol.lock.unlock() + let configuration = DeviceSession.configuration(); configuration.protocolClasses = [StubProtocol.self] + let storage = NotificationAuthStorage() + let state = AuthState(origin: origin, session: DeviceSession.make(configuration: configuration), settings: UserDefaults(suiteName: UUID().uuidString)!, storage: storage) + await state.restore(); await state.select(7) + var cleared = false; state.registerPrivateHolder(UUID()) { cleared = true } + let ending = Task { await state.invalidateSession(message: "Signed out.") } + for _ in 0..<500 { if gate.entered { break }; try await Task.sleep(nanoseconds: 10_000_000) } + guard gate.entered else { gate.release(); await ending.value; XCTFail("Owned cleanup never started"); return } + XCTAssertTrue(cleared); XCTAssertNil(state.user); XCTAssertTrue(state.memberships.isEmpty); XCTAssertNil(state.selectedID) + XCTAssertEqual(state.stage, .signIn); XCTAssertTrue(state.busy); XCTAssertTrue(state.endingSession) + XCTAssertNotNil(storage.load(), "Owned cleanup must finish before credentials are removed") + state.username = "new account"; state.password = "blocked"; await state.signIn(); XCTAssertNil(state.user) + gate.release(); await ending.value + XCTAssertFalse(state.busy); XCTAssertFalse(state.endingSession); XCTAssertNil(storage.load()); XCTAssertTrue(try vault.load().registrations.isEmpty) + } + func testConcurrentRoutingAndRegistrationShareFreshIdentityBeforeEveryWaiterContinues() async throws { + let gate = IdentityResponseGate(); defer { gate.release() } + let counter = ForegroundIdentityCounter() + StubProtocol.lock.lock() + StubProtocol.handler = { request in + if request.url?.path == "/api/v1/auth/me" { + counter.increment(); if counter.value > 1 { gate.wait() } + return (200, ["Content-Type": "application/json"], Data(DebugAuthFixtures.user.utf8)) + } + return (200, ["Content-Type": "application/json"], Data(DebugAuthFixtures.config.utf8)) + }; StubProtocol.lock.unlock() + let configuration = DeviceSession.configuration(); configuration.protocolClasses = [StubProtocol.self] + let state = AuthState(origin: try ServerOrigin("https://identity-coalesced.example"), session: DeviceSession.make(configuration: configuration), settings: UserDefaults(suiteName: UUID().uuidString)!, storage: NotificationAuthStorage()) + await state.restore(); await state.select(7) + let tasks = (0..<12).map { _ in Task { @MainActor in + let ids = try await state.freshMembershipIDs() + XCTAssertFalse(state.busy); XCTAssertEqual(state.stage, .signedIn) + return ids + } } + for _ in 0..<500 { if gate.entered { break }; try await Task.sleep(nanoseconds: 10_000_000) } + guard gate.entered else { gate.release(); for task in tasks { _ = try? await task.value }; XCTFail("Fresh identity was never requested"); return } + for _ in 0..<20 { await Task.yield() } + XCTAssertEqual(counter.value, 2); gate.release() + for task in tasks { let ids = try await task.value; XCTAssertEqual(ids, [7, 8]) } + XCTAssertEqual(counter.value, 2) + await state.invalidateSession(message: "Signed out.") + } + func testOldIdentityResponseCannotRestoreProtectedUIAfterLocalInvalidation() async throws { + let gate = IdentityResponseGate(); defer { gate.release() } + StubProtocol.lock.lock() + StubProtocol.handler = { request in + if request.url?.path == "/api/v1/auth/me" { gate.wait(); return (200, ["Content-Type": "application/json"], Data(DebugAuthFixtures.user.utf8)) } + return (200, ["Content-Type": "application/json"], Data(DebugAuthFixtures.config.utf8)) + } + StubProtocol.lock.unlock() + let configuration = DeviceSession.configuration(); configuration.protocolClasses = [StubProtocol.self] + let defaults = UserDefaults(suiteName: "notification-auth-" + UUID().uuidString)! + let storage = NotificationAuthStorage() + let state = AuthState(origin: try ServerOrigin("https://auth.example"), session: DeviceSession.make(configuration: configuration), settings: defaults, storage: storage) + let restoring = Task { await state.restore() } + for _ in 0..<500 { if gate.entered { break }; try await Task.sleep(nanoseconds: 10_000_000) } + guard gate.entered else { gate.release(); await restoring.value; XCTFail("Identity request was not issued"); return } + await state.invalidateSession(message: "Signed out.") + gate.release(); await restoring.value + XCTAssertEqual(state.stage, .signIn); XCTAssertNil(state.user); XCTAssertNil(state.selectedID); XCTAssertNil(state.notifications) + let credentials = await state.service.refresh.current(); XCTAssertNil(credentials) + } +} +private final class ForegroundIdentityCounter: @unchecked Sendable { + private let lock = NSLock(); private var count = 0 + var value: Int { lock.lock(); defer { lock.unlock() }; return count } + func increment() { lock.lock(); count += 1; lock.unlock() } +} +private final class IdentityResponseGate: @unchecked Sendable { + private let condition = NSCondition() + private var started = false + private var released = false + var entered: Bool { condition.lock(); defer { condition.unlock() }; return started } + func wait() { condition.lock(); started = true; while !released { condition.wait() }; condition.unlock() } + func release() { condition.lock(); released = true; condition.broadcast(); condition.unlock() } +} +private final class NotificationAuthStorage: TokenStorage, @unchecked Sendable { + private let lock = NSLock() + private var pair: TokenPair? = TokenPair(access: "old", refresh: "old-refresh") + func load() -> TokenPair? { lock.lock(); defer { lock.unlock() }; return pair } + func store(_ value: TokenPair?) { lock.lock(); defer { lock.unlock() }; pair = value } +} +#endif diff --git a/SpaceWorksTests/NotificationSettingsTests.swift b/SpaceWorksTests/NotificationSettingsTests.swift new file mode 100644 index 0000000..2331470 --- /dev/null +++ b/SpaceWorksTests/NotificationSettingsTests.swift @@ -0,0 +1,79 @@ +import XCTest +import UserNotifications +import SpaceWorksAPI +@testable import SpaceWorks + +@MainActor final class NotificationSettingsTests: XCTestCase { + private let token = String(repeating: "ab", count: 32) + func testUnconfiguredAndDemoNeverAskPermissionOrRegister() async { + for (configured, demo) in [(false, false), (true, true)] { + let native = NotificationTestDevice(); let repository = NotificationTestRepository() + let registration = PushRegistrationState(repository: repository, storage: NotificationTestStorage(), configured: configured, isDemo: demo) + let state = NotificationSettingsState(registration: registration, native: native, environment: "development", memberships: { XCTFail("Unexpected identity request"); return [] }) + await state.prepare(); await state.enable(); await state.refresh(); await state.tokenChanged(token) + XCTAssertEqual(native.requests, 0); XCTAssertEqual(native.refreshes, 0) + let events = await repository.events; XCTAssertTrue(events.isEmpty) + } + } + func testDeniedPermissionMakesNoServerRegistrationAndAllowsExplicitSettingsAction() async { + let native = NotificationTestDevice(); native.authorization = .denied + let repository = NotificationTestRepository(); var opened = false + let state = NotificationSettingsState(registration: PushRegistrationState(repository: repository, storage: NotificationTestStorage(), configured: true), native: native, environment: "development", memberships: { XCTFail("Permission denied should not register"); return [] }, settings: { opened = true }) + await state.enable(); XCTAssertTrue(state.permissionDenied); state.openSettings(); XCTAssertTrue(opened) + let events = await repository.events; XCTAssertTrue(events.isEmpty) + } + func testDelayedTokenUsesFreshMembershipsAndNeverRegistersBeforeExplicitEnable() async { + let native = NotificationTestDevice(); native.authorization = .authorized + let repository = NotificationTestRepository(); var lookups = 0 + let registration = PushRegistrationState(repository: repository, storage: NotificationTestStorage(), configured: true) + let state = NotificationSettingsState(registration: registration, native: native, environment: "development", memberships: { lookups += 1; return [7, 8] }) + await state.prepare(); await state.tokenChanged(token); XCTAssertEqual(native.requests, 0); XCTAssertEqual(lookups, 0) + await state.enable(); XCTAssertEqual(native.requests, 1); XCTAssertEqual(lookups, 0) + native.token = token; await state.tokenChanged(token) + XCTAssertEqual(lookups, 1); XCTAssertEqual(registration.registeredMakerspaces, [7, 8]) + await state.stop(authorizedDirectMembershipIDs: [7, 8]); await state.tokenChanged(token) + let events = await repository.events; XCTAssertEqual(events, ["register-7", "register-8", "delete-7", "delete-8"]) + } + func testIdentityRetryAfterBlocksRepeatedPermissionActionsAndTokenCallbacks() async { + let native = NotificationTestDevice(); native.authorization = .authorized; native.token = token + let repository = NotificationTestRepository(); var lookups = 0; let deadline = Date().addingTimeInterval(60) + let state = NotificationSettingsState(registration: PushRegistrationState(repository: repository, storage: NotificationTestStorage(), configured: true), native: native, environment: "development", memberships: { + lookups += 1; throw APIError(status: 429, code: "throttled", detail: "Wait before trying again.", retryAfter: deadline) + }) + await state.enable(); await state.enable(); await state.tokenChanged(token); await state.refresh() + XCTAssertEqual(lookups, 1); XCTAssertEqual(native.requests, 1); XCTAssertEqual(state.retryAfter, deadline) + let events = await repository.events; XCTAssertTrue(events.isEmpty) + } + func testFreshIdentityFailureDoesNotTrustPreviousMembershipsOrPublishEnabledState() async { + let native = NotificationTestDevice(); native.authorization = .authorized; native.token = token + let repository = NotificationTestRepository() + let registration = PushRegistrationState(repository: repository, storage: NotificationTestStorage(), configured: true) + let state = NotificationSettingsState(registration: registration, native: native, environment: "development", memberships: { throw URLError(.notConnectedToInternet) }) + await state.enable(); XCTAssertEqual(state.error, "Couldn't connect. Try again."); XCTAssertFalse(registration.enabled) + let events = await repository.events; XCTAssertTrue(events.isEmpty) + } +} +@MainActor private final class NotificationTestDevice: NativePushAccess { + var authorization = UNAuthorizationStatus.notDetermined + var token: String? + var error: String? + var requests = 0 + var refreshes = 0 + func requestPermission() async { requests += 1 } + func refreshAuthorization() async { refreshes += 1 } +} +private final class NotificationTestStorage: PushRegistrationStorage, @unchecked Sendable { + private let lock = NSLock() + private var snapshot = PushRegistrationSnapshot() + func load() -> PushRegistrationSnapshot { lock.lock(); defer { lock.unlock() }; return snapshot } + func store(_ value: PushRegistrationSnapshot) { lock.lock(); defer { lock.unlock() }; snapshot = value } +} +private actor NotificationTestRepository: PushRegistrationRepository { + private(set) var events: [String] = [] + func register(makerspaceID: Int, token: String, environment: String, authorizedDirectMembershipIDs: Set) throws -> Components.Schemas.PushDevice { + events.append("register-\(makerspaceID)") + let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(Components.Schemas.PushDevice.self, from: Data("{\"id\":\(makerspaceID),\"provider\":\"apns\",\"environment\":\"\(environment)\",\"makerspace_id\":\(makerspaceID),\"active\":true,\"created_at\":\"2026-10-16T09:00:00Z\",\"updated_at\":\"2026-10-16T09:00:00Z\"}".utf8)) + } + func unregister(makerspaceID: Int, deviceID: Int, authorizedDirectMembershipIDs: Set) { events.append("delete-\(makerspaceID)") } +} diff --git a/SpaceWorksTests/PushOperationTests.swift b/SpaceWorksTests/PushOperationTests.swift new file mode 100644 index 0000000..517d4bf --- /dev/null +++ b/SpaceWorksTests/PushOperationTests.swift @@ -0,0 +1,83 @@ +import XCTest +import SpaceWorksAPI +@testable import SpaceWorks + +final class PushOperationTests: XCTestCase { + private let origin = try! ServerOrigin("https://push.example") + private let token = String(repeating: "ab", count: 32) + override func tearDown() { StubProtocol.lock.lock(); StubProtocol.handler = nil; StubProtocol.lock.unlock() } + private func service(memberships: Set = [7, 8], handler: @escaping (URLRequest) throws -> (Int, [String: String], Data)) async throws -> SpaceWorksService { + StubProtocol.lock.lock(); StubProtocol.handler = handler; StubProtocol.lock.unlock() + let configuration = DeviceSession.configuration(); configuration.protocolClasses = [StubProtocol.self] + let service = SpaceWorksService(origin: origin, session: DeviceSession.make(configuration: configuration), initialTokens: .init(access: "fixture-access", refresh: "fixture-refresh")) + try await service.makerspace.select(7, activeMemberships: memberships) + return service + } + func testGeneratedRegistrationSupportsBothSuccessStatusesAndDeleteUsesActualReturnedIDWithoutChangingSelectedMaker() async throws { + let calls = PushOperationCounter() + let service = try await service { request in + let count = calls.next() + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer fixture-access") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Makerspace-Id"), "8") + for key in ["Cookie", "Origin", "Referer"] { XCTAssertNil(request.value(forHTTPHeaderField: key)) } + if request.httpMethod == "DELETE" { + XCTAssertEqual(request.url?.path, "/api/v1/integrations/push/devices/801") + return (204, [:], Data()) + } + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/api/v1/integrations/push/devices") + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: requestBody(request)) as? [String: String]) + XCTAssertEqual(Set(json.keys), ["token", "provider", "environment"]) + XCTAssertEqual(json["token"], self.token); XCTAssertEqual(json["provider"], "apns"); XCTAssertEqual(json["environment"], "development") + let body = #"{"id":801,"provider":"apns","environment":"development","makerspace_id":8,"active":true,"created_at":"2026-10-16T09:00:00Z","updated_at":"2026-10-16T09:00:00Z"}"# + return (count == 1 ? 201 : 200, ["Content-Type": "application/json"], Data(body.utf8)) + } + let repository = LivePushRegistrationRepository(service: service, expectedCredentials: await service.refresh.snapshot()) + for _ in 0..<2 { + let row = try await repository.register(makerspaceID: 8, token: token, environment: "development", authorizedDirectMembershipIDs: [7, 8]) + XCTAssertEqual(row.id, 801) + } + try await repository.unregister(makerspaceID: 8, deviceID: 801, authorizedDirectMembershipIDs: [7, 8]) + let selected = await service.makerspace.current() + XCTAssertEqual(selected, 7); XCTAssertEqual(calls.value, 3) + } + func testOldAccountWorkerCannotRegisterOrCleanUpUsingReplacementAccountCredentials() async throws { + let calls = PushOperationCounter() + let service = try await service { _ in _ = calls.next(); return (500, [:], Data()) } + let repository = LivePushRegistrationRepository(service: service, expectedCredentials: await service.refresh.snapshot()) + try await service.refresh.install(.init(access: "new-account", refresh: "new-account-refresh")) + do { _ = try await repository.register(makerspaceID: 7, token: token, environment: "development", authorizedDirectMembershipIDs: [7, 8]); XCTFail("Old owner registered using a new account") } + catch { XCTAssertTrue(PushOperationFailure.isCancellation(error)) } + do { try await repository.unregister(makerspaceID: 7, deviceID: 701, authorizedDirectMembershipIDs: [7, 8]); XCTFail("Old owner cleaned up using a new account") } + catch { XCTAssertTrue(PushOperationFailure.isCancellation(error)) } + XCTAssertEqual(calls.value, 0) + } + func testBackgroundPushScopeCannotBeUsedForAnOrdinaryAuthenticatedOperation() async throws { + let calls = PushOperationCounter() + let service = try await service { _ in _ = calls.next(); return (500, [:], Data()) } + do { + _ = try await service.withPushMakerspace(8, authorizedDirectMembershipIDs: [7, 8]) { try await service.me() } + XCTFail("Background push authorization escaped to an ordinary operation") + } catch { XCTAssertEqual(calls.value, 0) } + let selected = await service.makerspace.current(); XCTAssertEqual(selected, 7) + } + func testCallerSuppliedMembershipSetCannotGrantUnknownMakerspaceAndDelete404IsAlreadyClean() async throws { + let calls = PushOperationCounter() + let service = try await service(memberships: [7]) { request in + _ = calls.next() + XCTAssertEqual(request.httpMethod, "DELETE") + return (404, ["Content-Type": "application/json"], Data(#"{"detail":"Not found.","code":"not_found"}"#.utf8)) + } + let repository = LivePushRegistrationRepository(service: service, expectedCredentials: await service.refresh.snapshot()) + do { _ = try await repository.register(makerspaceID: 8, token: token, environment: "development", authorizedDirectMembershipIDs: [8]); XCTFail("Unknown membership was accepted") } + catch { XCTAssertEqual(calls.value, 0) } + try await repository.unregister(makerspaceID: 7, deviceID: 701, authorizedDirectMembershipIDs: [7]) + XCTAssertEqual(calls.value, 1) + } +} + +private final class PushOperationCounter: @unchecked Sendable { + private let lock = NSLock(); private var count = 0 + func next() -> Int { lock.lock(); defer { lock.unlock() }; count += 1; return count } + var value: Int { lock.lock(); defer { lock.unlock() }; return count } +} diff --git a/SpaceWorksTests/PushRegistrationTests.swift b/SpaceWorksTests/PushRegistrationTests.swift new file mode 100644 index 0000000..0074c11 --- /dev/null +++ b/SpaceWorksTests/PushRegistrationTests.swift @@ -0,0 +1,270 @@ +import XCTest +import Security +import SpaceWorksAPI +@testable import SpaceWorks + +@MainActor final class PushRegistrationTests: XCTestCase { + private let token = String(repeating: "ab", count: 32) + func testExplicitConsentRegistersEachDirectMembershipAndDeduplicatesRestoredDigest() async throws { + let repository = PushTestRepository(); let storage = PushMemoryStorage() + let state = PushRegistrationState(repository: repository, storage: storage, configured: true) + await state.synchronize(token: token, environment: "development", authorizedDirectMembershipIDs: [7, 8]) + let before = await repository.events; XCTAssertTrue(before.isEmpty) + await state.enable(token: token, environment: "development", authorizedDirectMembershipIDs: [7, 8]) + XCTAssertEqual(state.registeredMakerspaces, [7, 8]) + let saved = try storage.load() + XCTAssertTrue(saved.enabled); XCTAssertEqual(saved.registrations.count, 2) + XCTAssertFalse(String(decoding: try JSONEncoder().encode(saved), as: UTF8.self).contains(token)) + let restored = PushRegistrationState(repository: repository, storage: storage, configured: true) + await restored.load() + await restored.synchronize(token: token.uppercased(), environment: "development", authorizedDirectMembershipIDs: [7, 8]) + let after = await repository.events + XCTAssertEqual(after.map(\.kind), ["register", "register"]) + } + func testRotationDeletesActualOldIDsBeforeRegisteringReplacementAndEnvironmentChangeDoesToo() async { + let repository = PushTestRepository(); let state = PushRegistrationState(repository: repository, storage: PushMemoryStorage(), configured: true) + await state.enable(token: token, environment: "development", authorizedDirectMembershipIDs: [7]) + await state.synchronize(token: String(repeating: "cd", count: 32), environment: "development", authorizedDirectMembershipIDs: [7]) + await state.synchronize(token: String(repeating: "cd", count: 32), environment: "production", authorizedDirectMembershipIDs: [7]) + let events = await repository.events + XCTAssertEqual(events.map(\.kind), ["register", "delete", "register", "delete", "register"]) + XCTAssertEqual(events[1].deviceID, 701) + XCTAssertEqual(events[3].deviceID, 702) + } + func testPermissionPausePreservesConsentAndSameStateCanSynchronizeAfterRestoration() async throws { + let repository = PushTestRepository(); let storage = PushMemoryStorage() + let state = PushRegistrationState(repository: repository, storage: storage, configured: true) + await state.enable(token: token, environment: "development", authorizedDirectMembershipIDs: [7, 8]) + await state.pause(authorizedDirectMembershipIDs: [7, 8]) + XCTAssertTrue(state.enabled); XCTAssertTrue(try storage.load().enabled) + XCTAssertTrue(state.registeredMakerspaces.isEmpty); XCTAssertTrue(state.canRequest) + await state.synchronize(token: token, environment: "development", authorizedDirectMembershipIDs: [7, 8]) + let events = await repository.events + XCTAssertEqual(events.map(\.kind), ["register", "register", "delete", "delete", "register", "register"]) + XCTAssertEqual(state.registeredMakerspaces, [7, 8]) + } + func testPermissionPauseHonorsThrottleWithoutSendingCleanupRequests() async { + let repository = PushTestRepository() + let state = PushRegistrationState(repository: repository, storage: PushMemoryStorage(), configured: true) + await repository.failRegistration(status: 429, retryAfter: Date().addingTimeInterval(60)) + await state.enable(token: token, environment: "development", authorizedDirectMembershipIDs: [7]) + await state.pause(authorizedDirectMembershipIDs: [7]) + let events = await repository.events + XCTAssertEqual(events.map(\.kind), ["register"]); XCTAssertFalse(state.canRequest) + state.invalidate() + } + func testMetadataFailureCannotHideCleanupThrottleOrAuthenticationRefusal() async throws { + for status in [429, 401, 403] { + let repository = PushTestRepository(); let storage = PushMemoryStorage() + var unauthorized = 0; var forbidden = 0 + let state = PushRegistrationState(repository: repository, storage: storage, configured: true, + onUnauthorized: { _ in unauthorized += 1 }, onForbidden: { forbidden += 1 }) + await state.enable(token: token, environment: "development", authorizedDirectMembershipIDs: [7, 8]) + storage.failNonempty = true + let deadline = Date().addingTimeInterval(60) + await repository.failDeletion(status: status, retryAfter: status == 429 ? deadline : nil) + await state.disable(authorizedDirectMembershipIDs: [7, 8]) + XCTAssertEqual(state.error, "Push cleanup refused.") + XCTAssertEqual(unauthorized, status == 401 ? 1 : 0); XCTAssertEqual(forbidden, status == 403 ? 1 : 0) + XCTAssertEqual(Set(try storage.load().registrations.map(\.makerspaceID)), [7, 8], "Failed persistence must retain server IDs") + if status == 429 { + XCTAssertEqual(state.retryAfter, deadline); XCTAssertFalse(state.canRequest) + let before = await repository.events + await state.disable(authorizedDirectMembershipIDs: [7, 8]) + await state.pause(authorizedDirectMembershipIDs: [7, 8]) + let removed = await state.removeMembership(7, authorizedDirectMembershipIDs: [7, 8]) + let after = await repository.events + XCTAssertFalse(removed); XCTAssertEqual(before.count, after.count) + XCTAssertEqual(before.filter { $0.kind == "delete" }.count, 1) + } + state.invalidate() + } + } + func testCleanupThrottleBlocksRepeatedMutationsAndMembershipLeaveUntilConfirmed() async { + let repository = PushTestRepository() + let state = PushRegistrationState(repository: repository, storage: PushMemoryStorage(), configured: true) + await state.enable(token: token, environment: "development", authorizedDirectMembershipIDs: [7, 8]) + await repository.failDeletion(status: 429, retryAfter: Date().addingTimeInterval(60)) + let removed = await state.removeMembership(7, authorizedDirectMembershipIDs: [7, 8]) + XCTAssertFalse(removed); XCTAssertNotNil(state.error) + let before = await repository.events + await state.disable(authorizedDirectMembershipIDs: [7, 8]) + await state.resumeMembership(7, authorizedDirectMembershipIDs: [7, 8]) + XCTAssertGreaterThan(state.retryAfter, Date()) + let retry = await state.removeMembership(7, authorizedDirectMembershipIDs: [7, 8]) + let after = await repository.events + XCTAssertFalse(retry); XCTAssertEqual(after.count, before.count) + state.invalidate() + } + func testDemoAndUnconfiguredBuildNeverRegisterEvenWithPermissionOrCachedToken() async { + let repository = PushTestRepository() + for (configured, demo) in [(false, false), (true, true)] { + let state = PushRegistrationState(repository: repository, storage: PushMemoryStorage(), configured: configured, isDemo: demo) + await state.enable(token: token, environment: "development", authorizedDirectMembershipIDs: [7]) + await state.synchronize(token: token, environment: "development", authorizedDirectMembershipIDs: [7]) + XCTAssertFalse(state.canRequest); XCTAssertTrue(state.registeredMakerspaces.isEmpty) + } + let events = await repository.events; XCTAssertTrue(events.isEmpty) + } + func testInvalidTokenOrEnvironmentCannotPersistConsentOrSendAnAPIRequest() async { + let repository = PushTestRepository(); let storage = PushMemoryStorage() + let state = PushRegistrationState(repository: repository, storage: storage, configured: true) + await state.enable(token: "bad-token", environment: "development", authorizedDirectMembershipIDs: [7]) + await state.enable(token: token, environment: "sandbox", authorizedDirectMembershipIDs: [7]) + let events = await repository.events; XCTAssertTrue(events.isEmpty) + XCTAssertFalse((try? storage.load().enabled) ?? true) + } + func testStopWaitsForInFlightRegistrationAndBlocksLaterTokenCallbacksBeforeCleanup() async { + let repository = PushTestRepository(); let gate = PushTestGate(); await repository.setGate(gate) + let state = PushRegistrationState(repository: repository, storage: PushMemoryStorage(), configured: true) + let enabling = Task { await state.enable(token: token, environment: "development", authorizedDirectMembershipIDs: [7]) } + await gate.waitUntilEntered() + let stopping = Task { await state.stop(authorizedDirectMembershipIDs: [7]) } + while state.canRequest { await Task.yield() } + await state.synchronize(token: String(repeating: "ef", count: 32), environment: "development", authorizedDirectMembershipIDs: [7]) + await gate.release() + await enabling.value; await stopping.value + let events = await repository.events + XCTAssertEqual(events.map(\.kind), ["register", "delete"]) + XCTAssertFalse(state.canRequest); XCTAssertTrue(state.registeredMakerspaces.isEmpty) + } + func testLeavingMembershipSuspendsQueuedRegistrationUntilFreshFailedLeaveIsResumed() async throws { + let repository = PushTestRepository(); let coordinator = PushRegistrationCoordinator(repository: repository, storage: PushMemoryStorage()) + _ = try await coordinator.enable(token: token, environment: "development", authorizedDirectMembershipIDs: [7, 8]) + _ = try await coordinator.removeMembership(7, authorizedDirectMembershipIDs: [7, 8]) + _ = try await coordinator.reconcile(token: token, environment: "development", authorizedDirectMembershipIDs: [7, 8]) + let suspended = await repository.events + XCTAssertEqual(suspended.filter { $0.kind == "register" && $0.makerspaceID == 7 }.count, 1) + _ = try await coordinator.resumeMembership(7, authorizedDirectMembershipIDs: [7, 8]) + _ = try await coordinator.reconcile(token: token, environment: "development", authorizedDirectMembershipIDs: [7, 8]) + let resumed = await repository.events + XCTAssertEqual(resumed.filter { $0.kind == "register" && $0.makerspaceID == 7 }.count, 2) + } + func testLostMembershipNeverSendsCleanupThroughAnUnauthorizedScope() async { + let repository = PushTestRepository(); let storage = PushMemoryStorage() + let state = PushRegistrationState(repository: repository, storage: storage, configured: true) + await state.enable(token: token, environment: "development", authorizedDirectMembershipIDs: [7, 8]) + await state.synchronize(token: token, environment: "development", authorizedDirectMembershipIDs: [8]) + XCTAssertEqual(state.registeredMakerspaces, [8]) + let events = await repository.events; XCTAssertEqual(events.count, 2) + XCTAssertEqual(try? storage.load().registrations.map(\.makerspaceID), [8]) + } + func testDisablePersistsConsentOffAndPendingFailedDeletesForNextAuthorizedRetry() async throws { + let repository = PushTestRepository(); let storage = PushMemoryStorage() + let state = PushRegistrationState(repository: repository, storage: storage, configured: true) + await state.enable(token: token, environment: "development", authorizedDirectMembershipIDs: [7, 8]) + await repository.failDeletes([7]) + await state.disable(authorizedDirectMembershipIDs: [7, 8]) + let saved = try storage.load() + XCTAssertFalse(saved.enabled); XCTAssertEqual(saved.registrations.count, 1) + XCTAssertTrue(saved.registrations[0].pendingDeletion) + XCTAssertTrue(state.registeredMakerspaces.isEmpty) + await repository.failDeletes([]) + await state.enable(token: token, environment: "development", authorizedDirectMembershipIDs: [7, 8]) + XCTAssertEqual(state.registeredMakerspaces, [7, 8]) + } + func testNewServerIDIsCleanedIfItsSecurePersistenceFails() async { + let repository = PushTestRepository(); let storage = PushMemoryStorage(); storage.failNonempty = true + let state = PushRegistrationState(repository: repository, storage: storage, configured: true) + await state.enable(token: token, environment: "development", authorizedDirectMembershipIDs: [7]) + let events = await repository.events + XCTAssertEqual(events.map(\.kind), ["register", "delete"]) + XCTAssertTrue(state.registeredMakerspaces.isEmpty); XCTAssertNotNil(state.error) + } + func testUnauthorizedCallbackCanAwaitStopWithoutReenteringHeldSerialLock() async { + let repository = PushTestRepository(); await repository.failRegistration(status: 401) + let box = PushStateBox() + let state = PushRegistrationState(repository: repository, storage: PushMemoryStorage(), configured: true, + onUnauthorized: { _ in await box.stop() }) + box.state = state + await state.enable(token: token, environment: "development", authorizedDirectMembershipIDs: [7]) + XCTAssertFalse(state.canRequest); XCTAssertTrue(state.registeredMakerspaces.isEmpty) + } + func testThrottleKeepsRetryAfterAndPreventsAdditionalPermissionCallbackWrites() async { + let repository = PushTestRepository(); let deadline = Date().addingTimeInterval(60) + await repository.failRegistration(status: 429, retryAfter: deadline) + let state = PushRegistrationState(repository: repository, storage: PushMemoryStorage(), configured: true) + await state.enable(token: token, environment: "development", authorizedDirectMembershipIDs: [7]) + XCTAssertEqual(state.retryAfter, deadline) + await state.enable(token: token, environment: "development", authorizedDirectMembershipIDs: [7]) + await state.synchronize(token: token, environment: "development", authorizedDirectMembershipIDs: [7]) + let events = await repository.events; XCTAssertEqual(events.count, 1) + XCTAssertEqual(state.error, "Push refused.") + state.invalidate() + } + func testKeychainMetadataIsDeviceOnlyAndIsolatedByAccountAndOrigin() throws { + let origin = try ServerOrigin("https://push.example") + let service = "org.spaceworks.tests.push." + UUID().uuidString + let vault = PushRegistrationVault(origin: origin, accountID: 1, service: service) + defer { try? vault.remove() } + let record = PushStoredRegistration(makerspaceID: 7, deviceID: 701, tokenDigest: PushRegistrationVault.digest(token), environment: "development") + let value = PushRegistrationSnapshot(enabled: true, registrations: [record]) + try vault.store(value); XCTAssertEqual(try vault.load(), value) + XCTAssertTrue(try PushRegistrationVault(origin: origin, accountID: 2, service: service).load().registrations.isEmpty) + XCTAssertTrue(try PushRegistrationVault(origin: ServerOrigin("https://other.example"), accountID: 1, service: service).load().registrations.isEmpty) + let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, + kSecReturnAttributes as String: true, kSecMatchLimit as String: kSecMatchLimitOne] + var attributes: CFTypeRef? + XCTAssertEqual(SecItemCopyMatching(query as CFDictionary, &attributes), errSecSuccess) + XCTAssertEqual((attributes as? [String: Any])?[kSecAttrAccessible as String] as? String, kSecAttrAccessibleWhenUnlockedThisDeviceOnly as String) + } +} + +@MainActor private final class PushStateBox: @unchecked Sendable { + weak var state: PushRegistrationState? + func stop() async { await state?.stop(authorizedDirectMembershipIDs: [7]) } +} + +private final class PushMemoryStorage: PushRegistrationStorage, @unchecked Sendable { + private let lock = NSLock() + private var saved = PushRegistrationSnapshot() + var failNonempty = false + func load() throws -> PushRegistrationSnapshot { lock.lock(); defer { lock.unlock() }; return saved } + func store(_ snapshot: PushRegistrationSnapshot) throws { + lock.lock(); defer { lock.unlock() } + if failNonempty && !snapshot.registrations.isEmpty { throw APIError(status: 0, code: "storage_unavailable", detail: "Secure storage unavailable.") } + saved = snapshot + } +} + +private actor PushTestGate { + private var entered = false + private var waiter: CheckedContinuation? + private var observer: CheckedContinuation? + func arrive() async { entered = true; observer?.resume(); observer = nil; await withCheckedContinuation { waiter = $0 } } + func waitUntilEntered() async { if !entered { await withCheckedContinuation { observer = $0 } } } + func release() { waiter?.resume(); waiter = nil } +} + +private actor PushTestRepository: PushRegistrationRepository { + struct Event: Sendable { let kind: String; let makerspaceID: Int; let deviceID: Int } + private(set) var events: [Event] = [] + private var registerCounts: [Int: Int] = [:] + private var registrationFailure: APIError? + private var deletionFailure: APIError? + private var failedDeletes = Set() + private var gate: PushTestGate? + func setGate(_ value: PushTestGate) { gate = value } + func failDeletes(_ ids: Set) { failedDeletes = ids } + func failRegistration(status: Int, retryAfter: Date? = nil) { registrationFailure = APIError(status: status, code: "push_refused", detail: "Push refused.", retryAfter: retryAfter) } + func failDeletion(status: Int, retryAfter: Date? = nil) { deletionFailure = APIError(status: status, code: "push_cleanup_refused", detail: "Push cleanup refused.", retryAfter: retryAfter) } + func register(makerspaceID: Int, token: String, environment: String, authorizedDirectMembershipIDs: Set) async throws -> Components.Schemas.PushDevice { + XCTAssertTrue(authorizedDirectMembershipIDs.contains(makerspaceID)) + registerCounts[makerspaceID, default: 0] += 1 + let id = makerspaceID * 100 + registerCounts[makerspaceID, default: 0] + events.append(Event(kind: "register", makerspaceID: makerspaceID, deviceID: id)) + if let gate { self.gate = nil; await gate.arrive() } + if let registrationFailure { throw registrationFailure } + let json = """ + {"id":\(id),"provider":"apns","environment":"\(environment)","makerspace_id":\(makerspaceID),"active":true,"created_at":"2026-10-16T09:00:00Z","updated_at":"2026-10-16T09:00:00Z"} + """ + let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(Components.Schemas.PushDevice.self, from: Data(json.utf8)) + } + func unregister(makerspaceID: Int, deviceID: Int, authorizedDirectMembershipIDs: Set) async throws { + XCTAssertTrue(authorizedDirectMembershipIDs.contains(makerspaceID)) + events.append(Event(kind: "delete", makerspaceID: makerspaceID, deviceID: deviceID)) + if let deletionFailure { throw deletionFailure } + if failedDeletes.contains(makerspaceID) { throw APIError(status: 503, code: "offline", detail: "Offline.") } + } +} diff --git a/SpaceWorksUITests/NativeUIActions.swift b/SpaceWorksUITests/NativeUIActions.swift new file mode 100644 index 0000000..97be885 --- /dev/null +++ b/SpaceWorksUITests/NativeUIActions.swift @@ -0,0 +1,89 @@ +import XCTest + +/// Native bars can overlap the accessibility scroll frame. Keep gestures in visible content. +enum NativeUIActions { + static func tap(_ element: XCUIElement, in app: XCUIApplication) { + for attempt in 0..<24 { + let scroll = containingScroll(element, app) + if element.exists { + let insideScroll = contains(element, scroll) + if element.isHittable && !insideScroll { element.tap(); return } + let frame = element.frame + let viewport = visibleContent(scroll, app) + let visible = frame.intersection(viewport) + print("Native action \(element.identifier.isEmpty ? element.label : element.identifier), attempt \(attempt), frame \(frame), viewport \(viewport), hittable \(element.isHittable)") + // A SwiftUI Toggle can expose a label rectangle that differs from its + // native UISwitch activation point. Let XCTest activate a visible switch. + if element.elementType == .switch, element.isHittable, + !visible.isNull, visible.width > 4, visible.height > 4 { element.tap(); return } + if element.isHittable, !visible.isNull, visible.width > 4, visible.height > 4, + viewport.insetBy(dx: 2, dy: 2).contains(CGPoint(x: frame.midX, y: frame.midY)) { + // Use the native activation point. A wide Toggle's label centre is not + // its switch thumb; coordinates are only needed for clipped text fields. + element.tap(); return + } + if (element.elementType == .textField || element.elementType == .secureTextField), + element.isHittable, !visible.isNull, visible.width > 8, visible.height > 8 { + element.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: visible.midX - frame.minX, dy: visible.midY - frame.minY)).tap(); return + } + // Off-screen accessibility frames can jump across the viewport after + // rotation. Reversing on their midpoint oscillated around hidden controls. + // Sweep consistently in each direction; activation still requires a real + // visible, hittable target. New screens normally start near their top. + drag(scroll, app, down: attempt >= 12) + } else { drag(scroll, app, down: attempt >= 12) } + } + XCTFail("Action must be reachable after scrolling visible content.") + } + private static func contains(_ element: XCUIElement, _ scroll: XCUIElement) -> Bool { + guard element.exists, scroll.exists else { return false } + let descendants = scroll.descendants(matching: element.elementType) + if !element.identifier.isEmpty { return descendants.matching(identifier: element.identifier).firstMatch.exists } + guard !element.label.isEmpty else { return false } + return descendants.matching(NSPredicate(format: "label == %@", element.label)).firstMatch.exists + } + private static func containingScroll(_ element: XCUIElement, _ app: XCUIApplication) -> XCUIElement { + let scrolls = app.scrollViews.allElementsBoundByIndex.filter { $0.exists && $0.isHittable } + // Presented SwiftUI sheets may appear in a backing view's AX descendants. + // Its scroll cannot move the modal content. Prefer the actual review container. + if let review = scrolls.first(where: { $0.identifier == "request-review-scroll" }) { return review } + let containing = scrolls.filter { contains(element, $0) } + return containing.min(by: { area($0.frame.intersection(app.frame)) < area($1.frame.intersection(app.frame)) }) + ?? scrolls.first ?? app.scrollViews.firstMatch + } + private static func area(_ rect: CGRect) -> CGFloat { rect.isNull ? .greatestFiniteMagnitude : rect.width * rect.height } + private static func visibleContent(_ scroll: XCUIElement, _ app: XCUIApplication) -> CGRect { + var viewport = scroll.exists ? scroll.frame.intersection(app.frame) : app.frame + // SwiftUI's accessibility ScrollView frame includes content UNDER native bars. + // A target can be marked hittable there while its coordinate activates another tab. + for bar in app.navigationBars.allElementsBoundByIndex where bar.exists && bar.isHittable { + let frame = bar.frame + if frame.intersects(viewport), frame.midY < viewport.midY { + let bottom = viewport.maxY; viewport.origin.y = max(viewport.minY, frame.maxY); viewport.size.height = max(0, bottom - viewport.minY) + } + } + // A tab bar hidden behind a modal is not part of the modal viewport. + let lowerBars = app.tabBars.allElementsBoundByIndex.filter { $0.exists && $0.isHittable } + + app.keyboards.allElementsBoundByIndex.filter { $0.exists } + for bar in lowerBars { + let frame = bar.frame + if frame.intersects(viewport), frame.midY > viewport.minY { viewport.size.height = max(0, min(viewport.maxY, frame.minY) - viewport.minY) } + } + return viewport.insetBy(dx: 8, dy: 8) + } + private static func drag(_ scroll: XCUIElement, _ app: XCUIApplication, down: Bool) { + let viewport = visibleContent(scroll, app) + guard viewport.width > 20, viewport.height > 30 else { return } + // Drag entirely inside the actual visible content, never across tab/navigation bars. + // A fast released drag flung landscape content ~300pt across a 139pt + // viewport. Hold at a slow, bounded endpoint so the next visible target is + // examined before momentum can carry it under the navigation/tab bars. + let distance = min(200, viewport.height * 0.4) + let start = CGPoint(x: viewport.midX, y: viewport.midY + (down ? -distance : distance) / 2) + let end = CGPoint(x: viewport.midX, y: viewport.midY + (down ? distance : -distance) / 2) + app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: start.x - app.frame.minX, dy: start.y - app.frame.minY)) + .press(forDuration: 0.05, + thenDragTo: app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: end.x - app.frame.minX, dy: end.y - app.frame.minY)), + withVelocity: .slow, thenHoldForDuration: 0.3) + } +} diff --git a/SpaceWorksUITests/NotificationsFlowTests.swift b/SpaceWorksUITests/NotificationsFlowTests.swift new file mode 100644 index 0000000..1fb876e --- /dev/null +++ b/SpaceWorksUITests/NotificationsFlowTests.swift @@ -0,0 +1,57 @@ +import XCTest + +final class NotificationsFlowTests: XCTestCase { + override func setUp() { continueAfterFailure = false; XCUIDevice.shared.orientation = .portrait } + private func tap(_ element: XCUIElement, _ app: XCUIApplication) { NativeUIActions.tap(element, in: app) } + private func tab(_ app: XCUIApplication, _ name: String) { + if app.tabBars.buttons[name].exists { tap(app.tabBars.buttons[name], app); return } + let item = app.descendants(matching: .any)["nav-\(name)"].firstMatch + let ready = NSPredicate(format: "exists == true AND isHittable == true") + if XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: ready, object: item)], timeout: 3) != .completed { + let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'sidebar'")).firstMatch + if sidebar.exists { sidebar.tap() } + } + tap(item, app) + } + private func notifications() -> XCUIApplication { + let app = XCUIApplication(); app.launchArguments = ["--ui-testing"]; app.launch() + tap(app.buttons["explore-demo"], app); tap(app.buttons["Continue as member"], app) + let username = app.textFields["demo-username"]; tap(username, app); username.typeText("meera\n") + app.secureTextFields["demo-password"].typeText("sample\n") + tap(app.buttons["demo-sign-in"], app); tap(app.buttons["demo-space-0"], app) + XCTAssertTrue(app.staticTexts["Welcome back, Meera Nair."].waitForExistence(timeout: 10)) + tab(app, "Profile"); tap(app.descendants(matching: .any)["notifications-entry"].firstMatch, app) + return app + } + private func capture(_ name: String) { + let image = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); image.name = name; image.lifetime = .keepAlways; add(image) + } + func testPermissionDeniedAndUnauthorizedTapHaveRecoverableInAppStates() { + let app = notifications(); tap(app.switches["Preview permission denied"], app) + XCTAssertTrue(app.staticTexts["Notifications are off for this device."].exists) + tap(app.buttons["Open system settings"], app) + XCTAssertTrue(app.staticTexts["notification-preview-feedback"].exists); XCTAssertEqual(app.state, .runningForeground) + tap(app.switches["Preview permission denied"], app); tap(app.buttons["enable-demo-notifications"], app) + tap(app.buttons["demo-push-denied"], app) + XCTAssertTrue(app.staticTexts["mobile-link-error"].waitForExistence(timeout: 5)) + XCTAssertEqual(app.staticTexts["mobile-link-error"].label, "This makerspace isn't available to your account.") + capture("Notification permission and authorization refusal") + tap(app.buttons["Dismiss"], app) + XCTAssertTrue(app.buttons["demo-push-product"].exists) + } + func testNotificationRefetchOpensProductAndAnotherAuthorizedMakerspaceInboxAcrossRotation() { + let app = notifications(); tap(app.buttons["enable-demo-notifications"], app) + tap(app.buttons["demo-push-product"], app) + XCTAssertTrue(app.staticTexts["Digital multimeter"].firstMatch.waitForExistence(timeout: 8)) + XCTAssertTrue(app.buttons["Add to request"].exists); capture("Authorized notification opens fresh inventory detail") + tab(app, "Profile") + if !app.buttons["demo-push-other-space"].exists { tap(app.descendants(matching: .any)["notifications-entry"].firstMatch, app) } + tap(app.buttons["demo-push-other-space"], app) + XCTAssertTrue(app.buttons["Mark all as read"].waitForExistence(timeout: 8)) + XCTAssertTrue(app.buttons["Maker Space Bengaluru"].exists) + XCUIDevice.shared.orientation = .landscapeLeft + let landscape = NSPredicate { _, _ in app.frame.width > app.frame.height } + XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: landscape, object: app)], timeout: 5), .completed) + capture("Notification switches authorized makerspace then opens Inbox landscape") + } +} diff --git a/branding/brand.json b/branding/brand.json index 985d32d..c567180 100644 --- a/branding/brand.json +++ b/branding/brand.json @@ -7,5 +7,7 @@ "support_url": null, "privacy_url": null, "theme_tokens": "design/tokens/tokens.json", - "app_icon": "branding/app-icon.svg" + "app_icon": "branding/app-icon.svg", + "aps_environment": null, + "associated_domains": [] } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 93dc2f4..41bc0da 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,7 +1,47 @@ # Phase notes +## A4 Notifications and trusted routing — implementation checkpoint (2026-10-17) + +Wire the native notification delegate, contextual permission UI, per-account device-only Keychain +registration metadata and generated create/delete operations. Consent differs from OS permission; +revoked permission pauses delivery and cleans up server IDs without erasing account consent. Token +rotation reconciles each freshly confirmed direct membership without changing the selected UI scope. +Only the two push operations can use the background makerspace override. Logout removes protected identity, screen holders and drafts immediately, resets SDK callbacks +synchronously with an ownership guard, awaits serialized server cleanup, then revokes/wipes credentials. +Cleanup authentication and Retry-After errors take precedence over simultaneous Keychain write failures; +pending server IDs remain recoverable and throttled cleanup cannot send repeated deletes. +Offline deletion remains pending; SDK callbacks cannot prove registration or delivery. Each live push +worker retains its credential installation epoch, so queued old-account work cannot send under a new +account. Foreground receipt hides untrusted remote banner content and refreshes only the current +maker's inbox after a fresh identity check, without navigation or scope changes. + +Hints and URLs never choose a deployment or grant access. Current-origin native paths use an explicit +allowlist; current Inbox `/m/{slug}/member` links resolve the exact slug against fresh direct memberships. +Staff web-inbox links remain closed without native staff authority. Unknown versions fall back to the +freshly authorized Inbox. Conflicting slug/ID hints cannot change scope. Every push first refetches the authorized inbox. Identity, selection and actual +resource refetch must succeed before a one-use navigation ticket opens; stale account/view responses +cannot navigate. Read refusal, unavailable resources and Retry-After have visible recovery states. + +The owner can set explicit `aps_environment` and `associated_domains` brand inputs with their actual +Apple team; prebuild regeneration preserves the validated configuration. Unconfigured builds have no +invented APNs or universal-link domains, and simulator signing excludes APNs. The DEBUG demo has +isolated permission/enable/token-rotation/tap previews, cross-makerspace navigation and refusal states; +it never asks OS permission, launches push SDK setup or registers a token. + +Coalesce routing, token registration and foreground identity reads, releasing busy state before every +waiter continues. Add installation-epoch checks before publishing foreground/restored identity so an old `/me` response +cannot restore protected UI after session invalidation. Preserve privacy and draft state across app +backgrounding, and retain scope-aware notification navigation across phone/tablet layout changes. + +Parity: Notifications / Enable notifications / Notifications are off for this device. / Open system +settings / Open update. Local generation/capability/source checks pass; native state/API/UI tests and +rendered phone/tablet evidence await the A4 compiler checkpoint. Real APNs delivery, server link files, +provider keys and cross-deployment payload attribution remain deferred backend/release inputs. + ## A3 Member actions — implementation checkpoint (2026-10-16) +Completion evidence: [`e427626` CI38974366101](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/38974366101) passed 109 native state/API tests and 18 native UI journeys on both phone and tablet, 14 Python generation/fixture checks, and the unsigned physical-device Release build. These are contract-fixture/compiler results; real device/provider/backend validation remains a release gate. + Add thirteen generated operations for tenant bootstrap, reviewed hardware requests, loan renewal, space availability/booking/cancellation, event registration/cancellation/ticket QR, account contact profile and communication preferences. Hardware retries keep an idempotency key for the reviewed diff --git a/docs/PUSH-NOTIFICATIONS.md b/docs/PUSH-NOTIFICATIONS.md new file mode 100644 index 0000000..2b0a568 --- /dev/null +++ b/docs/PUSH-NOTIFICATIONS.md @@ -0,0 +1,82 @@ +# Native push integration + +This phase uses the pinned generated `api_v1_integrations_push_devices_create` (200/201) and +`api_v1_integrations_push_devices_destroy` (204) operations. Create sends exactly token, provider +`apns`, and the signed build's development/production environment. The selected makerspace header +owns each registration; no makerspace ID is invented in the JSON body. Delete404 is already clean. + +`PushRegistrationState` owns one server/account. Construct it with +`LivePushRegistrationRepository(service:)`, `PushRegistrationVault(origin:accountID:)`, the actual +build's configured status, and the existing unauthorized/forbidden transitions. Never construct +an enabled live registrar from Demo. The state has no OS permission or SDK calls; the owning +notification UI first explains push and requests native permission only after an explicit action. + +- `load()` restores the account-scoped consent and known IDs. +- `enable(token:environment:authorizedDirectMembershipIDs:)` records explicit consent and reconciles + each current direct membership. Tokens are bounded hexadecimal APNs bytes, not a fixed32-byte rule. +- `synchronize(...)` handles token rotation/foreground/current membership refresh only when consent + already exists. A matching digest/environment avoids duplicate registration after process restart. +- `disable(authorizedDirectMembershipIDs:)` stores consent off before best-effort server cleanup. +- `pause(authorizedDirectMembershipIDs:)` handles externally revoked OS permission: cleanup + preserves account consent and the state remains usable for synchronization after permission + is restored. New token callbacks are blocked while cleanup awaits; throttled requests wait. +- `stop(authorizedDirectMembershipIDs:)` immediately refuses later token callbacks, waits for the + in-flight serialized transaction, deletes known registrations and invalidates private UI state. + The auth owner first clears SDK callbacks, the cached OS token and OS registration synchronously + through `NativeNotifications.reset()`, then awaits this cleanup BEFORE device logout, + password/security session ending or token removal. Its owner UUID prevents an older async + cleanup from resetting a newer account/server's SDK callbacks. Logout preserves that account's consent; it does not grant a + different account permission. Failed server deletes remain pending securely for a later login. +- `removeMembership(_:authorizedDirectMembershipIDs:)` runs BEFORE a voluntary leave while it can + still authorize that scope. It suspends that maker against a queued old-token callback. If the + cleanup returns `false`, do not submit the leave mutation: surface the cleanup error or retained + retry deadline. API mutations honor that deadline; logout's `stop` remains best effort. + If the leave fails and fresh identity confirms membership remains, `resumeMembership(...)` permits the + next synchronization. Local resume sends no request and preserves any throttle deadline. A successful leave never resumes it. Authority loss cannot authorize a + cleanup call; local routing refuses that old maker and its metadata is dropped. + +The coordinator explicitly serializes across awaits; actor isolation alone does not. Main-actor +authorization callbacks execute only after the serial lock is released, allowing the auth owner +to await cleanup without deadlock. SDK callbacks never prove server registration. Register +responses must confirm the actual ID/maker/provider/environment/active state before publication. + +The device-only, non-synchronizing Keychain stores consent, returned IDs, environment and SHA-256 +token digests. No raw APNs token is persisted by this layer. It scopes metadata by normalized server +origin and actual account ID. If a newly returned ID cannot be stored, immediate cleanup is tried +and its pending metadata remains in memory for logout. Offline cleanup cannot be claimed as a +successful server deletion; unregistering the SDK and authorization checks remain necessary. + +`SpaceWorksService.withPushMakerspace(_:authorizedDirectMembershipIDs:expectedCredentials:operation:)` must independently +check its current direct membership set, permit only the two generated push operation IDs, and +revalidate scope after refresh. It must not change the UI selection or create a second refresh +coordinator. A caller-provided set is not authority. Ordinary API calls cannot use this background +override. The API tests exercise background registration while retaining the selected maker, +scope escape refusal, unknown membership refusal, both success statuses and delete404. + +Native capabilities require the owner's real bundle/team, Push provisioning, APNs provider key, +server topics and supported device-session/payload contract. The owner deferred those inputs. +In `branding/brand.json`, `aps_environment` is either `null` (unconfigured) or `"production"`; +the latter generates development APNs for Debug and production APNs for Release. Optional +`associated_domains` is an explicit list such as `["applinks:members.example.org"]` or +`["webcredentials:members.example.org"]`. Configuring either capability requires the owner's +valid ten-character Apple team ID. The generator preserves these owner settings, and simulator +signing excludes APNs entitlements. The server must separately publish and authorize its real +associated-domain file. +Simulator stubs and Debug fixtures do not prove real APNs delivery or production authorization. +The existing inbox remains available without notification permission or push configuration. + +The pinned hint payload identifies a makerspace but does not identify its deployment origin. +Offline logout can leave an old server registration, and the OS may later reuse its APNs token. +The app reauthorizes and fetches all content from the current server before navigation; precise +cross-deployment attribution still needs an origin-bound server hint contract. Keep live push +unconfigured until the owner supplies and verifies that deployment contract. + +The live registration repository captures its owning credential installation epoch when identity is +validated. Queued work cannot register or delete using a replacement account's credentials, even if +the same makerspace is selected. A replaced owner detaches locally and retains its own vault metadata +for future authorized cleanup; it does not clean up through its successor. + +Foreground receipt never displays untrusted APNs body text. A validated hint for the currently selected +maker queues a fresh identity check and then refreshes that maker's inbox/unread count. It neither +navigates nor changes makerspace; hints for other makers wait for an explicit tap. Failed identity or +read requests cannot publish cached content as a successful refresh. diff --git a/scripts/generate.py b/scripts/generate.py index 3673af8..73f740b 100644 --- a/scripts/generate.py +++ b/scripts/generate.py @@ -45,10 +45,18 @@ def generate(raster=True): + f"BRAND_BUNDLE_ID = {brand['ios_bundle_id']}\n" + f"BRAND_DISPLAY_NAME = {brand['app_name']}\n" + f"BRAND_TEAM_ID = {brand['ios_team_id']}\n") + capability_spec = importlib.util.spec_from_file_location("spaceworks_capabilities", ROOT / "scripts/mobile_capabilities.py") + capability_module = importlib.util.module_from_spec(capability_spec) + capability_spec.loader.exec_module(capability_module) + deployment = capability_module.capabilities(brand, "Release") + push_configured = "aps-environment" in deployment swift_literal = lambda value: "nil" if value is None else json.dumps(value, ensure_ascii=False) write(ROOT / "SpaceWorks/Generated/Brand.swift", "// Generated. Do not edit.\nimport Foundation\nenum Brand {\n" + "\n".join(f" static let {name}: String{'?' if key in ['server_url', 'support_url', 'privacy_url'] else ''} = {swift_literal(brand[key])}" for name, key in [("appName", "app_name"), ("bundleID", "ios_bundle_id"), ("serverURL", "server_url"), ("supportURL", "support_url"), ("privacyURL", "privacy_url")]) + + "\n static let pushConfigured = " + str(push_configured).lower() + + "\n #if DEBUG\n static let pushEnvironment = \"development\"\n #else\n static let pushEnvironment = \"production\"\n #endif\n" + + " static let associatedDomains: [String] = " + json.dumps(brand.get("associated_domains", [])) + "\n}\n") for mode in ["Debug", "Release"]: info = {"CFBundleDisplayName": brand["app_name"], "UILaunchScreen": {}, "UIApplicationSceneManifest": {"UIApplicationSupportsMultipleScenes": False}} @@ -67,9 +75,11 @@ def generate(raster=True): info["NSAppTransportSecurity"] = {"NSAllowsLocalNetworking": True} target = ROOT / f"SpaceWorks/Generated/Info-{mode}.plist" target.write_bytes(plistlib.dumps(info)) - (ROOT / f"SpaceWorks/Generated/Attestation-{mode}.entitlements").write_bytes(plistlib.dumps({ + entitlements = { "com.apple.developer.devicecheck.appattest-environment": "development" if mode == "Debug" else "production", - "com.apple.developer.applesignin": ["Default"]})) + "com.apple.developer.applesignin": ["Default"]} + entitlements.update(capability_module.capabilities(brand, mode)) + (ROOT / f"SpaceWorks/Generated/Attestation-{mode}.entitlements").write_bytes(plistlib.dumps(entitlements)) # Simulator Keychain requires an effective signed app identity, even without an owner team. # This local-only identity is never used in a physical-device build or release archive. simulator_identity = "LOCALCI." + brand["ios_bundle_id"] diff --git a/scripts/mobile_capabilities.py b/scripts/mobile_capabilities.py new file mode 100644 index 0000000..9439dde --- /dev/null +++ b/scripts/mobile_capabilities.py @@ -0,0 +1,35 @@ +"""Validate optional owner capabilities; missing configuration never invents a server domain.""" +import re +import ipaddress + + +def capabilities(brand, mode): + if mode not in ("Debug", "Release"): + raise ValueError("Unknown build configuration") + apns = brand.get("aps_environment") + if apns not in (None, "production"): + raise ValueError("aps_environment must be production or null; Debug uses development automatically") + domains = brand.get("associated_domains", []) + if not isinstance(domains, list) or len(domains) > 20 or not all(isinstance(value, str) for value in domains) or len(set(domains)) != len(domains): + raise ValueError("associated_domains must be a unique list of at most 20 explicit domains") + for entry in domains: + if not isinstance(entry, str) or ":" not in entry: + raise ValueError("Invalid associated domain") + service, hostname = entry.split(":", 1) + try: + ipaddress.ip_address(hostname) + except ValueError: + pass + else: + raise ValueError("Associated domains require an explicit DNS hostname") + labels = hostname.split(".") + if service not in ("applinks", "webcredentials") or len(hostname) > 253 or len(labels) < 2 or not all(re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label) for label in labels): + raise ValueError("Use an explicit applinks: or webcredentials: DNS hostname without URL, port, wildcard or path") + if (apns is not None or domains) and not re.fullmatch(r"[A-Z0-9]{10}", brand.get("ios_team_id", "")): + raise ValueError("Configure the actual ten-character ios_team_id before enabling Apple capabilities") + result = {} + if apns is not None: + result["aps-environment"] = "development" if mode == "Debug" else "production" + if domains: + result["com.apple.developer.associated-domains"] = domains + return result diff --git a/scripts/tests/test_mobile_capabilities.py b/scripts/tests/test_mobile_capabilities.py new file mode 100644 index 0000000..8ed6cd8 --- /dev/null +++ b/scripts/tests/test_mobile_capabilities.py @@ -0,0 +1,71 @@ +import importlib.util +import json +import plistlib +import shutil +import tempfile +from unittest import mock +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location("capabilities", ROOT / "scripts/mobile_capabilities.py") +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class CapabilityTests(unittest.TestCase): + def test_unconfigured_build_has_no_apns_or_invented_domains(self): + self.assertEqual(module.capabilities({}, "Debug"), {}) + self.assertEqual(module.capabilities({"aps_environment": None, "associated_domains": []}, "Release"), {}) + + def test_explicit_owner_configuration_preserves_domains_and_separates_debug_environment(self): + brand = {"ios_team_id": "ABC1234567", "aps_environment": "production", "associated_domains": ["applinks:lab.example", "webcredentials:lab.example"]} + debug = module.capabilities(brand, "Debug") + release = module.capabilities(brand, "Release") + self.assertEqual(debug["aps-environment"], "development") + self.assertEqual(release["aps-environment"], "production") + self.assertEqual(release["com.apple.developer.associated-domains"], brand["associated_domains"]) + + def test_prebuild_preserves_explicit_capabilities_and_removes_disabled_ones(self): + generation_spec = importlib.util.spec_from_file_location("capability_generation", ROOT / "scripts/generate.py") + generator = importlib.util.module_from_spec(generation_spec) + generation_spec.loader.exec_module(generator) + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + for relative in ["branding/brand.json", "api/openapi.json", "api/provenance.json", "tokens/mobile-motion.json", "design/tokens/tokens.json"]: + target = root / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(ROOT / relative, target) + shutil.copytree(ROOT / "scripts", root / "scripts", ignore=shutil.ignore_patterns("__pycache__")) + shutil.copytree(ROOT / "Fixtures/Member", root / "Fixtures/Member") + brand_file = root / "branding/brand.json" + brand = json.loads(brand_file.read_text(encoding="utf-8")) + brand.update(ios_team_id="ABC1234567", aps_environment="production", associated_domains=["applinks:lab.example"]) + brand_file.write_text(json.dumps(brand), encoding="utf-8") + with mock.patch.object(generator, "ROOT", root), mock.patch.object(generator.shutil, "copyfile"): + generator.generate(raster=False) + for mode, environment in [("Debug", "development"), ("Release", "production")]: + values = plistlib.loads((root / f"SpaceWorks/Generated/Attestation-{mode}.entitlements").read_bytes()) + self.assertEqual(values["aps-environment"], environment) + self.assertEqual(values["com.apple.developer.associated-domains"], ["applinks:lab.example"]) + self.assertNotIn("aps-environment", plistlib.loads((root / "SpaceWorks/Generated/Simulator.entitlements").read_bytes())) + self.assertIn("pushConfigured = true", (root / "SpaceWorks/Generated/Brand.swift").read_text()) + brand.update(aps_environment=None, associated_domains=[]) + brand_file.write_text(json.dumps(brand), encoding="utf-8") + generator.generate(raster=False) + values = plistlib.loads((root / "SpaceWorks/Generated/Attestation-Release.entitlements").read_bytes()) + self.assertNotIn("aps-environment", values) + self.assertNotIn("com.apple.developer.associated-domains", values) + self.assertIn("pushConfigured = false", (root / "SpaceWorks/Generated/Brand.swift").read_text()) + + def test_invalid_domains_team_and_environment_are_rejected(self): + for value in ["applinks:*.example", "https://lab.example", "applinks:lab.example/path", "applinks:lab.example:443", "applinks:lab.example?mode=developer", "applinks:localhost", "applinks:user@lab.example"]: + with self.subTest(value=value), self.assertRaises(ValueError): + module.capabilities({"ios_team_id": "ABC1234567", "associated_domains": [value]}, "Release") + for brand in [{"aps_environment": "development"}, {"aps_environment": "production"}, {"associated_domains": ["applinks:lab.example"]}]: + with self.assertRaises(ValueError): + module.capabilities(brand, "Debug") + + +if __name__ == "__main__": + unittest.main() From 0466acb4e50b535df10bec1ab25465138845723c Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:26:49 +0530 Subject: [PATCH 24/98] test(a4): resolve the canonical macOS fixture root Temporary directories can pass through /var symlinks on macOS. Match the generator's canonical repository boundary without relaxing asset containment. Co-Authored-By: Codex --- scripts/tests/test_mobile_capabilities.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tests/test_mobile_capabilities.py b/scripts/tests/test_mobile_capabilities.py index 8ed6cd8..2b21b6c 100644 --- a/scripts/tests/test_mobile_capabilities.py +++ b/scripts/tests/test_mobile_capabilities.py @@ -31,7 +31,7 @@ def test_prebuild_preserves_explicit_capabilities_and_removes_disabled_ones(self generator = importlib.util.module_from_spec(generation_spec) generation_spec.loader.exec_module(generator) with tempfile.TemporaryDirectory() as directory: - root = pathlib.Path(directory) + root = pathlib.Path(directory).resolve() for relative in ["branding/brand.json", "api/openapi.json", "api/provenance.json", "tokens/mobile-motion.json", "design/tokens/tokens.json"]: target = root / relative target.parent.mkdir(parents=True, exist_ok=True) From fdafeed894dd1ce8fa3b9e4d4a6465c3f8bbfacd Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:10:38 +0530 Subject: [PATCH 25/98] test(a4): scroll the parent outside multiline controls Largest Dynamic Type can fill the review viewport with the purpose field. Keep real bounded drags in the page gutter so its nested text scrolling cannot trap the parent; retain visible and hittable activation checks. Co-Authored-By: Codex --- SpaceWorksUITests/MemberActionsFlowTests.swift | 7 +++++-- SpaceWorksUITests/NativeUIActions.swift | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index 2654048..cd2f3cb 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -81,8 +81,11 @@ final class MemberActionsFlowTests: XCTestCase { // viewport. Hold at a slow, bounded endpoint so the next visible target is // examined before momentum can carry it under the navigation/tab bars. let distance = min(200, viewport.height * 0.4) - let start = CGPoint(x: viewport.midX, y: viewport.midY + (down ? -distance : distance) / 2) - let end = CGPoint(x: viewport.midX, y: viewport.midY + (down ? distance : -distance) / 2) + // Multiline fields have their own scroll view. Drag in the page gutter so + // the parent moves even when a tall field occupies the viewport centre. + let x = viewport.minX + min(12, viewport.width * 0.1) + let start = CGPoint(x: x, y: viewport.midY + (down ? -distance : distance) / 2) + let end = CGPoint(x: x, y: viewport.midY + (down ? distance : -distance) / 2) app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: start.x - app.frame.minX, dy: start.y - app.frame.minY)) .press(forDuration: 0.05, thenDragTo: app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: end.x - app.frame.minX, dy: end.y - app.frame.minY)), diff --git a/SpaceWorksUITests/NativeUIActions.swift b/SpaceWorksUITests/NativeUIActions.swift index 97be885..fac5c29 100644 --- a/SpaceWorksUITests/NativeUIActions.swift +++ b/SpaceWorksUITests/NativeUIActions.swift @@ -79,8 +79,11 @@ enum NativeUIActions { // viewport. Hold at a slow, bounded endpoint so the next visible target is // examined before momentum can carry it under the navigation/tab bars. let distance = min(200, viewport.height * 0.4) - let start = CGPoint(x: viewport.midX, y: viewport.midY + (down ? -distance : distance) / 2) - let end = CGPoint(x: viewport.midX, y: viewport.midY + (down ? distance : -distance) / 2) + // Multiline fields have their own scroll view. Drag in the page gutter so + // the parent moves even when a tall field occupies the viewport centre. + let x = viewport.minX + min(12, viewport.width * 0.1) + let start = CGPoint(x: x, y: viewport.midY + (down ? -distance : distance) / 2) + let end = CGPoint(x: x, y: viewport.midY + (down ? distance : -distance) / 2) app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: start.x - app.frame.minX, dy: start.y - app.frame.minY)) .press(forDuration: 0.05, thenDragTo: app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: end.x - app.frame.minX, dy: end.y - app.frame.minY)), From 00f636da0753023634eed489466b686afb567314 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:52:18 +0530 Subject: [PATCH 26/98] test(a4): keep generic drags in their content column Co-Authored-By: Codex --- .../MemberActionsFlowTests.swift | 22 ++++++++++++++----- SpaceWorksUITests/NativeUIActions.swift | 22 ++++++++++++++----- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index cd2f3cb..501bd4d 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -30,8 +30,8 @@ final class MemberActionsFlowTests: XCTestCase { // rotation. Reversing on their midpoint oscillated around hidden controls. // Sweep consistently in each direction; activation still requires a real // visible, hittable target. New screens normally start near their top. - drag(scroll, app, down: attempt >= 12) - } else { drag(scroll, app, down: attempt >= 12) } + drag(scroll, app, target: element, down: attempt >= 12) + } else { drag(scroll, app, target: element, down: attempt >= 12) } } let target = element.exists ? (element.identifier.isEmpty ? element.label : element.identifier) : "missing control" capture("Unreachable action " + target) @@ -73,7 +73,7 @@ final class MemberActionsFlowTests: XCTestCase { } return viewport.insetBy(dx: 8, dy: 8) } - private func drag(_ scroll: XCUIElement, _ app: XCUIApplication, down: Bool) { + private func drag(_ scroll: XCUIElement, _ app: XCUIApplication, target: XCUIElement, down: Bool) { let viewport = visibleContent(scroll, app) guard viewport.width > 20, viewport.height > 30 else { return } // Drag entirely inside the actual visible content, never across tab/navigation bars. @@ -81,9 +81,19 @@ final class MemberActionsFlowTests: XCTestCase { // viewport. Hold at a slow, bounded endpoint so the next visible target is // examined before momentum can carry it under the navigation/tab bars. let distance = min(200, viewport.height * 0.4) - // Multiline fields have their own scroll view. Drag in the page gutter so - // the parent moves even when a tall field occupies the viewport centre. - let x = viewport.minX + min(12, viewport.width * 0.1) + // The modal's multiline purpose field owns an inner scroll; its gutter + // moves the parent. A generic tablet viewport may include the sidebar, so + // keep other gestures within the target's horizontal content column. + var x = viewport.midX + if scroll.identifier == "request-review-scroll" { + x = viewport.minX + min(12, viewport.width * 0.1) + } else if target.exists { + let frame = target.frame + if frame.minX.isFinite, frame.maxX.isFinite, frame.width > 0 { + let lower = max(viewport.minX, frame.minX), upper = min(viewport.maxX, frame.maxX) + if upper - lower > 24 { x = (lower + upper) / 2 } + } + } let start = CGPoint(x: x, y: viewport.midY + (down ? -distance : distance) / 2) let end = CGPoint(x: x, y: viewport.midY + (down ? distance : -distance) / 2) app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: start.x - app.frame.minX, dy: start.y - app.frame.minY)) diff --git a/SpaceWorksUITests/NativeUIActions.swift b/SpaceWorksUITests/NativeUIActions.swift index fac5c29..78d8e45 100644 --- a/SpaceWorksUITests/NativeUIActions.swift +++ b/SpaceWorksUITests/NativeUIActions.swift @@ -30,8 +30,8 @@ enum NativeUIActions { // rotation. Reversing on their midpoint oscillated around hidden controls. // Sweep consistently in each direction; activation still requires a real // visible, hittable target. New screens normally start near their top. - drag(scroll, app, down: attempt >= 12) - } else { drag(scroll, app, down: attempt >= 12) } + drag(scroll, app, target: element, down: attempt >= 12) + } else { drag(scroll, app, target: element, down: attempt >= 12) } } XCTFail("Action must be reachable after scrolling visible content.") } @@ -71,7 +71,7 @@ enum NativeUIActions { } return viewport.insetBy(dx: 8, dy: 8) } - private static func drag(_ scroll: XCUIElement, _ app: XCUIApplication, down: Bool) { + private static func drag(_ scroll: XCUIElement, _ app: XCUIApplication, target: XCUIElement, down: Bool) { let viewport = visibleContent(scroll, app) guard viewport.width > 20, viewport.height > 30 else { return } // Drag entirely inside the actual visible content, never across tab/navigation bars. @@ -79,9 +79,19 @@ enum NativeUIActions { // viewport. Hold at a slow, bounded endpoint so the next visible target is // examined before momentum can carry it under the navigation/tab bars. let distance = min(200, viewport.height * 0.4) - // Multiline fields have their own scroll view. Drag in the page gutter so - // the parent moves even when a tall field occupies the viewport centre. - let x = viewport.minX + min(12, viewport.width * 0.1) + // The modal's multiline purpose field owns an inner scroll; its gutter + // moves the parent. A generic tablet viewport may include the sidebar, so + // keep other gestures within the target's horizontal content column. + var x = viewport.midX + if scroll.identifier == "request-review-scroll" { + x = viewport.minX + min(12, viewport.width * 0.1) + } else if target.exists { + let frame = target.frame + if frame.minX.isFinite, frame.maxX.isFinite, frame.width > 0 { + let lower = max(viewport.minX, frame.minX), upper = min(viewport.maxX, frame.maxX) + if upper - lower > 24 { x = (lower + upper) / 2 } + } + } let start = CGPoint(x: x, y: viewport.midY + (down ? -distance : distance) / 2) let end = CGPoint(x: x, y: viewport.midY + (down ? distance : -distance) / 2) app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: start.x - app.frame.minX, dy: start.y - app.frame.minY)) From d058b8bdfd81da6c33724353f4b4da5060039df7 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:24:27 +0530 Subject: [PATCH 27/98] test(a4): drag within the review card padding Co-Authored-By: Codex --- .../MemberActionsFlowTests.swift | 23 +++++++++++++++++-- SpaceWorksUITests/NativeUIActions.swift | 14 +++++++++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index 501bd4d..adb7291 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -3,6 +3,8 @@ import XCTest final class MemberActionsFlowTests: XCTestCase { override func setUp() { continueAfterFailure = false; XCUIDevice.shared.orientation = .portrait } private func tap(_ element: XCUIElement, in app: XCUIApplication) { + var previousReviewFrame: CGRect? + var capturedReviewStall = false for attempt in 0..<24 { let scroll = containingScroll(element, app) if element.exists { @@ -11,6 +13,13 @@ final class MemberActionsFlowTests: XCTestCase { let frame = element.frame let viewport = visibleContent(scroll, app) let visible = frame.intersection(viewport) + if scroll.identifier == "request-review-scroll" { + if !capturedReviewStall, previousReviewFrame == frame { + capture("Review scroll stalled before " + element.identifier) + capturedReviewStall = true + } + previousReviewFrame = frame + } print("Native action \(element.identifier.isEmpty ? element.label : element.identifier), attempt \(attempt), frame \(frame), viewport \(viewport), hittable \(element.isHittable)") // A SwiftUI Toggle can expose a label rectangle that differs from its // native UISwitch activation point. Let XCTest activate a visible switch. @@ -85,8 +94,14 @@ final class MemberActionsFlowTests: XCTestCase { // moves the parent. A generic tablet viewport may include the sidebar, so // keep other gestures within the target's horizontal content column. var x = viewport.midX - if scroll.identifier == "request-review-scroll" { - x = viewport.minX + min(12, viewport.width * 0.1) + if scroll.identifier == "request-review-scroll", target.exists { + // Stay inside the card's padding. The outer screen gutter can be + // captured by the sheet's pan recognizer instead of its scroll view. + // The purpose editor begins at the target's leading content edge. + let leading = target.frame.minX + if leading.isFinite { + x = min(viewport.maxX - 12, max(viewport.minX + 12, leading - 12)) + } } else if target.exists { let frame = target.frame if frame.minX.isFinite, frame.maxX.isFinite, frame.width > 0 { @@ -96,6 +111,10 @@ final class MemberActionsFlowTests: XCTestCase { } let start = CGPoint(x: x, y: viewport.midY + (down ? -distance : distance) / 2) let end = CGPoint(x: x, y: viewport.midY + (down ? distance : -distance) / 2) + if scroll.identifier == "request-review-scroll" { + let purpose = app.descendants(matching: .any)["request-purpose"].firstMatch + print("Review drag \(start) -> \(end), scroll \(scroll.frame), purpose \(purpose.exists ? String(describing: purpose.frame) : "absent")") + } app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: start.x - app.frame.minX, dy: start.y - app.frame.minY)) .press(forDuration: 0.05, thenDragTo: app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: end.x - app.frame.minX, dy: end.y - app.frame.minY)), diff --git a/SpaceWorksUITests/NativeUIActions.swift b/SpaceWorksUITests/NativeUIActions.swift index 78d8e45..f1f2a7a 100644 --- a/SpaceWorksUITests/NativeUIActions.swift +++ b/SpaceWorksUITests/NativeUIActions.swift @@ -83,8 +83,14 @@ enum NativeUIActions { // moves the parent. A generic tablet viewport may include the sidebar, so // keep other gestures within the target's horizontal content column. var x = viewport.midX - if scroll.identifier == "request-review-scroll" { - x = viewport.minX + min(12, viewport.width * 0.1) + if scroll.identifier == "request-review-scroll", target.exists { + // Stay inside the card's padding. The outer screen gutter can be + // captured by the sheet's pan recognizer instead of its scroll view. + // The purpose editor begins at the target's leading content edge. + let leading = target.frame.minX + if leading.isFinite { + x = min(viewport.maxX - 12, max(viewport.minX + 12, leading - 12)) + } } else if target.exists { let frame = target.frame if frame.minX.isFinite, frame.maxX.isFinite, frame.width > 0 { @@ -94,6 +100,10 @@ enum NativeUIActions { } let start = CGPoint(x: x, y: viewport.midY + (down ? -distance : distance) / 2) let end = CGPoint(x: x, y: viewport.midY + (down ? distance : -distance) / 2) + if scroll.identifier == "request-review-scroll" { + let purpose = app.descendants(matching: .any)["request-purpose"].firstMatch + print("Review drag \(start) -> \(end), scroll \(scroll.frame), purpose \(purpose.exists ? String(describing: purpose.frame) : "absent")") + } app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: start.x - app.frame.minX, dy: start.y - app.frame.minY)) .press(forDuration: 0.05, thenDragTo: app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: end.x - app.frame.minX, dy: end.y - app.frame.minY)), From 3e757403d28bf8dbc75ed95f127469e6c77d91d1 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:04:56 +0530 Subject: [PATCH 28/98] test(a4): inspect native review scrolling geometry Co-Authored-By: Codex --- .github/workflows/ios-scroll-diagnostic.yml | 112 ++++++++++++++++++ SpaceWorks/MemberActionViews.swift | 2 +- .../Platform/ReviewScrollDiagnostics.swift | 79 ++++++++++++ .../MemberActionsFlowTests.swift | 6 + 4 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ios-scroll-diagnostic.yml create mode 100644 SpaceWorks/Platform/ReviewScrollDiagnostics.swift diff --git a/.github/workflows/ios-scroll-diagnostic.yml b/.github/workflows/ios-scroll-diagnostic.yml new file mode 100644 index 0000000..a7fc3a5 --- /dev/null +++ b/.github/workflows/ios-scroll-diagnostic.yml @@ -0,0 +1,112 @@ +# Supplemental investigation only. Ordinary ios.yml remains the phase acceptance gate. +name: iOS scroll diagnostics +on: + push: + paths: + - .github/workflows/ios-scroll-diagnostic.yml + - SpaceWorks/Platform/ReviewScrollDiagnostics.swift + workflow_dispatch: +permissions: + contents: read +concurrency: + group: ios-scroll-diagnostic-${{ github.ref }} + cancel-in-progress: true +jobs: + build-and-test: + runs-on: macos-15 + env: + DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer + timeout-minutes: 40 + name: Targeted scroll investigation (not phase acceptance) + strategy: + fail-fast: false + max-parallel: 1 + matrix: + device: [phone] + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Verify pinned Xcode and SDK + run: | + xcodebuild -version + xcrun --sdk iphonesimulator --show-sdk-version + xcrun --sdk iphoneos --show-sdk-version + - name: Install project and icon generators + run: brew install xcodegen librsvg + - name: Restore Swift package sources + id: package-cache + uses: actions/cache/restore@v4 + with: + path: SourcePackages + key: ${{ runner.os }}-${{ runner.arch }}-spm-v2-${{ hashFiles('project.yml', 'Packages/**/Package.swift') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-spm-v2- + ${{ runner.os }}-${{ runner.arch }}-spm- + - name: Identify Xcode build cache + id: xcode-cache + shell: bash + run: | + XCODE_BUILD=$(xcodebuild -version | tr '\n' '-' | tr -cd '[:alnum:].-') + SIM_SDK_BUILD=$(xcrun --sdk iphonesimulator --show-sdk-build-version) + DEVICE_SDK_BUILD=$(xcrun --sdk iphoneos --show-sdk-build-version) + echo "identity=$XCODE_BUILD-$SIM_SDK_BUILD-$DEVICE_SDK_BUILD" >> "$GITHUB_OUTPUT" + - name: Cache compiled dependencies and build intermediates + id: compiled-cache + uses: actions/cache/restore@v4 + with: + path: | + DerivedData/Build + DerivedData/ModuleCache.noindex + DerivedData/SDKStatCaches.noindex + key: ios-build-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.xcode-cache.outputs.identity }}-${{ hashFiles('project.yml', 'Packages/**/Package.swift') }}-${{ matrix.device }}-${{ github.sha }} + restore-keys: | + ios-build-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.xcode-cache.outputs.identity }}-${{ hashFiles('project.yml', 'Packages/**/Package.swift') }}-${{ matrix.device }}- + ios-build-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.xcode-cache.outputs.identity }}-${{ hashFiles('project.yml', 'Packages/**/Package.swift') }}- + - name: Generate and validate source inputs + run: | + python3 scripts/generate.py + python3 -m unittest discover -s scripts/tests -v + python3 scripts/lint.py + xcodegen generate + - name: Select phone or tablet simulator + run: | + python3 scripts/device-matrix.py '${{ matrix.device }}' + - name: Run the single failing journey for geometry diagnostics + run: | + set -o pipefail + xcodebuild test -project SpaceWorks.xcodeproj -scheme SpaceWorks -only-testing:SpaceWorksUITests/MemberActionsFlowTests/testLargestTextDarkRequestReviewPortraitAndLandscape -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -resultBundlePath TestResults.xcresult -skipPackagePluginValidation ONLY_ACTIVE_ARCH=YES CODE_SIGN_STYLE=Manual CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= | tee build.log + - name: Export UI screenshots + if: always() + run: | + if [ -d TestResults.xcresult ]; then + xcrun xcresulttool export attachments --path TestResults.xcresult --output-path TestAttachments || true + fi + - name: Archive supplementary diagnostics (not acceptance) + if: always() + uses: actions/upload-artifact@v4 + with: + name: ios-scroll-diagnostic-${{ matrix.device }} + path: | + TestResults.xcresult + TestAttachments + build.log + validation-device.json + DerivedData/Build/Intermediates.noindex/BuildToolPluginIntermediates/**/GeneratedSources/*.swift + SpaceWorks.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved + retention-days: 7 + - name: Save Swift package sources after validation + if: always() && !cancelled() && steps.package-cache.outputs.cache-primary-key != '' && steps.package-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: SourcePackages + key: ${{ steps.package-cache.outputs.cache-primary-key }} + - name: Save compiler cache after validation + if: always() && !cancelled() && steps.compiled-cache.outputs.cache-primary-key != '' && steps.compiled-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + DerivedData/Build + DerivedData/ModuleCache.noindex + DerivedData/SDKStatCaches.noindex + key: ${{ steps.compiled-cache.outputs.cache-primary-key }} diff --git a/SpaceWorks/MemberActionViews.swift b/SpaceWorks/MemberActionViews.swift index d968482..842fab4 100644 --- a/SpaceWorks/MemberActionViews.swift +++ b/SpaceWorks/MemberActionViews.swift @@ -49,7 +49,7 @@ struct RequestCartView: View { } } }.padding(24).frame(maxWidth: 720, alignment: .leading).frame(maxWidth: .infinity).disabled(state.busy) - }.background(Color.swBg).scrollDismissesKeyboard(.interactively).accessibilityIdentifier("request-review-scroll") + }.background(Color.swBg).scrollDismissesKeyboard(.interactively).accessibilityIdentifier("request-review-scroll").requestScrollDiagnostics() .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close") { dismiss() }.frame(minHeight: 48).disabled(state.busy) } } }.interactiveDismissDisabled(state.busy) } diff --git a/SpaceWorks/Platform/ReviewScrollDiagnostics.swift b/SpaceWorks/Platform/ReviewScrollDiagnostics.swift new file mode 100644 index 0000000..cc04490 --- /dev/null +++ b/SpaceWorks/Platform/ReviewScrollDiagnostics.swift @@ -0,0 +1,79 @@ +import SwiftUI + +/// Geometry-only diagnostics for native UI tests. No content, credentials or gestures are exposed. +extension View { + @ViewBuilder func requestScrollDiagnostics() -> some View { + #if DEBUG + if ProcessInfo.processInfo.arguments.contains("--ui-testing") { + self.overlay(alignment: .center) { + ReviewScrollProbe().frame(width: 1, height: 1).allowsHitTesting(false) + } + } else { self } + #else + self + #endif + } +} + +#if DEBUG +import UIKit + +private struct ReviewScrollProbe: UIViewRepresentable { + func makeUIView(context: Context) -> ReviewScrollProbeView { ReviewScrollProbeView() } + func updateUIView(_ view: ReviewScrollProbeView, context: Context) {} +} + +private final class ReviewScrollProbeView: UIView { + init() { + super.init(frame: .zero) + isUserInteractionEnabled = false + isAccessibilityElement = true + accessibilityIdentifier = "review-scroll-diagnostics" + accessibilityLabel = "Review scroll geometry" + backgroundColor = .clear + } + required init?(coder: NSCoder) { return nil } + override var accessibilityValue: String? { + get { + guard let window else { return "No attached window" } + var remaining = 512 + func scrolls(_ view: UIView, depth: Int) -> [[String: String]] { + guard depth < 40, remaining > 0 else { return [] } + remaining -= 1 + var result: [[String: String]] = [] + if let scroll = view as? UIScrollView { + result.append([ + "class": String(describing: type(of: scroll)), + "windowFrame": NSStringFromCGRect(scroll.convert(scroll.bounds, to: window)), + "bounds": NSStringFromCGRect(scroll.bounds), + "contentSize": NSStringFromCGSize(scroll.contentSize), + "offset": NSStringFromCGPoint(scroll.contentOffset), + "insets": NSStringFromUIEdgeInsets(scroll.adjustedContentInset), + "enabled": String(scroll.isScrollEnabled), + "pan": String(scroll.panGestureRecognizer.state.rawValue) + ]) + } + for child in view.subviews { result += scrolls(child, depth: depth + 1) } + return result + } + func hit(_ point: CGPoint) -> [String] { + var view = window.hitTest(point, with: nil) + var result = [NSStringFromCGPoint(point)] + while let current = view, result.count < 20 { + result.append(String(describing: type(of: current))) + view = current.superview + } + return result + } + let metadata: [String: Any] = [ + "scrolls": scrolls(window, depth: 0), + "leadingHit": hit(CGPoint(x: 36, y: 590.5)), + "centreHit": hit(CGPoint(x: window.bounds.midX, y: 590.5)) + ] + guard let data = try? JSONSerialization.data(withJSONObject: metadata, options: [.sortedKeys]) else { return "Geometry unavailable" } + return String(decoding: data, as: UTF8.self) + } + set { super.accessibilityValue = newValue } + } +} +#endif diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index adb7291..8702617 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -14,7 +14,13 @@ final class MemberActionsFlowTests: XCTestCase { let viewport = visibleContent(scroll, app) let visible = frame.intersection(viewport) if scroll.identifier == "request-review-scroll" { + if attempt == 0 { + let diagnostics = app.descendants(matching: .any)["review-scroll-diagnostics"].firstMatch + print("Initial native review metadata: \(diagnostics.exists ? String(describing: diagnostics.value) : "probe unavailable")") + } if !capturedReviewStall, previousReviewFrame == frame { + let diagnostics = app.descendants(matching: .any)["review-scroll-diagnostics"].firstMatch + print("Native review metadata: \(diagnostics.exists ? String(describing: diagnostics.value) : "probe unavailable")") capture("Review scroll stalled before " + element.identifier) capturedReviewStall = true } From 89bee9e50025ec93189c0cc520b11f4a0d2fb05d Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:17:53 +0530 Subject: [PATCH 29/98] fix(a4): format probe geometry with current Swift APIs Co-Authored-By: Codex --- SpaceWorks/Platform/ReviewScrollDiagnostics.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/SpaceWorks/Platform/ReviewScrollDiagnostics.swift b/SpaceWorks/Platform/ReviewScrollDiagnostics.swift index cc04490..816a181 100644 --- a/SpaceWorks/Platform/ReviewScrollDiagnostics.swift +++ b/SpaceWorks/Platform/ReviewScrollDiagnostics.swift @@ -44,11 +44,11 @@ private final class ReviewScrollProbeView: UIView { if let scroll = view as? UIScrollView { result.append([ "class": String(describing: type(of: scroll)), - "windowFrame": NSStringFromCGRect(scroll.convert(scroll.bounds, to: window)), - "bounds": NSStringFromCGRect(scroll.bounds), - "contentSize": NSStringFromCGSize(scroll.contentSize), - "offset": NSStringFromCGPoint(scroll.contentOffset), - "insets": NSStringFromUIEdgeInsets(scroll.adjustedContentInset), + "windowFrame": String(describing: scroll.convert(scroll.bounds, to: window)), + "bounds": String(describing: scroll.bounds), + "contentSize": String(describing: scroll.contentSize), + "offset": String(describing: scroll.contentOffset), + "insets": String(describing: scroll.adjustedContentInset), "enabled": String(scroll.isScrollEnabled), "pan": String(scroll.panGestureRecognizer.state.rawValue) ]) @@ -58,7 +58,7 @@ private final class ReviewScrollProbeView: UIView { } func hit(_ point: CGPoint) -> [String] { var view = window.hitTest(point, with: nil) - var result = [NSStringFromCGPoint(point)] + var result = [String(describing: point)] while let current = view, result.count < 20 { result.append(String(describing: type(of: current))) view = current.superview From 55dd1903478ae25518620d487736e439ecf11188 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:59:00 +0530 Subject: [PATCH 30/98] test(a4): keep modal scrolling aimed at the target Keep the same bounded search and strict activation while following verified modal target edges. Remove temporary geometry instrumentation before the full native gate. Co-Authored-By: Codex --- .github/workflows/ios-scroll-diagnostic.yml | 112 ------------------ SpaceWorks/MemberActionViews.swift | 2 +- .../Platform/ReviewScrollDiagnostics.swift | 79 ------------ .../MemberActionsFlowTests.swift | 38 +++--- SpaceWorksUITests/NativeUIActions.swift | 22 +++- 5 files changed, 40 insertions(+), 213 deletions(-) delete mode 100644 .github/workflows/ios-scroll-diagnostic.yml delete mode 100644 SpaceWorks/Platform/ReviewScrollDiagnostics.swift diff --git a/.github/workflows/ios-scroll-diagnostic.yml b/.github/workflows/ios-scroll-diagnostic.yml deleted file mode 100644 index a7fc3a5..0000000 --- a/.github/workflows/ios-scroll-diagnostic.yml +++ /dev/null @@ -1,112 +0,0 @@ -# Supplemental investigation only. Ordinary ios.yml remains the phase acceptance gate. -name: iOS scroll diagnostics -on: - push: - paths: - - .github/workflows/ios-scroll-diagnostic.yml - - SpaceWorks/Platform/ReviewScrollDiagnostics.swift - workflow_dispatch: -permissions: - contents: read -concurrency: - group: ios-scroll-diagnostic-${{ github.ref }} - cancel-in-progress: true -jobs: - build-and-test: - runs-on: macos-15 - env: - DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer - timeout-minutes: 40 - name: Targeted scroll investigation (not phase acceptance) - strategy: - fail-fast: false - max-parallel: 1 - matrix: - device: [phone] - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - name: Verify pinned Xcode and SDK - run: | - xcodebuild -version - xcrun --sdk iphonesimulator --show-sdk-version - xcrun --sdk iphoneos --show-sdk-version - - name: Install project and icon generators - run: brew install xcodegen librsvg - - name: Restore Swift package sources - id: package-cache - uses: actions/cache/restore@v4 - with: - path: SourcePackages - key: ${{ runner.os }}-${{ runner.arch }}-spm-v2-${{ hashFiles('project.yml', 'Packages/**/Package.swift') }} - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-spm-v2- - ${{ runner.os }}-${{ runner.arch }}-spm- - - name: Identify Xcode build cache - id: xcode-cache - shell: bash - run: | - XCODE_BUILD=$(xcodebuild -version | tr '\n' '-' | tr -cd '[:alnum:].-') - SIM_SDK_BUILD=$(xcrun --sdk iphonesimulator --show-sdk-build-version) - DEVICE_SDK_BUILD=$(xcrun --sdk iphoneos --show-sdk-build-version) - echo "identity=$XCODE_BUILD-$SIM_SDK_BUILD-$DEVICE_SDK_BUILD" >> "$GITHUB_OUTPUT" - - name: Cache compiled dependencies and build intermediates - id: compiled-cache - uses: actions/cache/restore@v4 - with: - path: | - DerivedData/Build - DerivedData/ModuleCache.noindex - DerivedData/SDKStatCaches.noindex - key: ios-build-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.xcode-cache.outputs.identity }}-${{ hashFiles('project.yml', 'Packages/**/Package.swift') }}-${{ matrix.device }}-${{ github.sha }} - restore-keys: | - ios-build-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.xcode-cache.outputs.identity }}-${{ hashFiles('project.yml', 'Packages/**/Package.swift') }}-${{ matrix.device }}- - ios-build-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.xcode-cache.outputs.identity }}-${{ hashFiles('project.yml', 'Packages/**/Package.swift') }}- - - name: Generate and validate source inputs - run: | - python3 scripts/generate.py - python3 -m unittest discover -s scripts/tests -v - python3 scripts/lint.py - xcodegen generate - - name: Select phone or tablet simulator - run: | - python3 scripts/device-matrix.py '${{ matrix.device }}' - - name: Run the single failing journey for geometry diagnostics - run: | - set -o pipefail - xcodebuild test -project SpaceWorks.xcodeproj -scheme SpaceWorks -only-testing:SpaceWorksUITests/MemberActionsFlowTests/testLargestTextDarkRequestReviewPortraitAndLandscape -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -resultBundlePath TestResults.xcresult -skipPackagePluginValidation ONLY_ACTIVE_ARCH=YES CODE_SIGN_STYLE=Manual CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= | tee build.log - - name: Export UI screenshots - if: always() - run: | - if [ -d TestResults.xcresult ]; then - xcrun xcresulttool export attachments --path TestResults.xcresult --output-path TestAttachments || true - fi - - name: Archive supplementary diagnostics (not acceptance) - if: always() - uses: actions/upload-artifact@v4 - with: - name: ios-scroll-diagnostic-${{ matrix.device }} - path: | - TestResults.xcresult - TestAttachments - build.log - validation-device.json - DerivedData/Build/Intermediates.noindex/BuildToolPluginIntermediates/**/GeneratedSources/*.swift - SpaceWorks.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved - retention-days: 7 - - name: Save Swift package sources after validation - if: always() && !cancelled() && steps.package-cache.outputs.cache-primary-key != '' && steps.package-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 - with: - path: SourcePackages - key: ${{ steps.package-cache.outputs.cache-primary-key }} - - name: Save compiler cache after validation - if: always() && !cancelled() && steps.compiled-cache.outputs.cache-primary-key != '' && steps.compiled-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 - with: - path: | - DerivedData/Build - DerivedData/ModuleCache.noindex - DerivedData/SDKStatCaches.noindex - key: ${{ steps.compiled-cache.outputs.cache-primary-key }} diff --git a/SpaceWorks/MemberActionViews.swift b/SpaceWorks/MemberActionViews.swift index 842fab4..d968482 100644 --- a/SpaceWorks/MemberActionViews.swift +++ b/SpaceWorks/MemberActionViews.swift @@ -49,7 +49,7 @@ struct RequestCartView: View { } } }.padding(24).frame(maxWidth: 720, alignment: .leading).frame(maxWidth: .infinity).disabled(state.busy) - }.background(Color.swBg).scrollDismissesKeyboard(.interactively).accessibilityIdentifier("request-review-scroll").requestScrollDiagnostics() + }.background(Color.swBg).scrollDismissesKeyboard(.interactively).accessibilityIdentifier("request-review-scroll") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close") { dismiss() }.frame(minHeight: 48).disabled(state.busy) } } }.interactiveDismissDisabled(state.busy) } diff --git a/SpaceWorks/Platform/ReviewScrollDiagnostics.swift b/SpaceWorks/Platform/ReviewScrollDiagnostics.swift deleted file mode 100644 index 816a181..0000000 --- a/SpaceWorks/Platform/ReviewScrollDiagnostics.swift +++ /dev/null @@ -1,79 +0,0 @@ -import SwiftUI - -/// Geometry-only diagnostics for native UI tests. No content, credentials or gestures are exposed. -extension View { - @ViewBuilder func requestScrollDiagnostics() -> some View { - #if DEBUG - if ProcessInfo.processInfo.arguments.contains("--ui-testing") { - self.overlay(alignment: .center) { - ReviewScrollProbe().frame(width: 1, height: 1).allowsHitTesting(false) - } - } else { self } - #else - self - #endif - } -} - -#if DEBUG -import UIKit - -private struct ReviewScrollProbe: UIViewRepresentable { - func makeUIView(context: Context) -> ReviewScrollProbeView { ReviewScrollProbeView() } - func updateUIView(_ view: ReviewScrollProbeView, context: Context) {} -} - -private final class ReviewScrollProbeView: UIView { - init() { - super.init(frame: .zero) - isUserInteractionEnabled = false - isAccessibilityElement = true - accessibilityIdentifier = "review-scroll-diagnostics" - accessibilityLabel = "Review scroll geometry" - backgroundColor = .clear - } - required init?(coder: NSCoder) { return nil } - override var accessibilityValue: String? { - get { - guard let window else { return "No attached window" } - var remaining = 512 - func scrolls(_ view: UIView, depth: Int) -> [[String: String]] { - guard depth < 40, remaining > 0 else { return [] } - remaining -= 1 - var result: [[String: String]] = [] - if let scroll = view as? UIScrollView { - result.append([ - "class": String(describing: type(of: scroll)), - "windowFrame": String(describing: scroll.convert(scroll.bounds, to: window)), - "bounds": String(describing: scroll.bounds), - "contentSize": String(describing: scroll.contentSize), - "offset": String(describing: scroll.contentOffset), - "insets": String(describing: scroll.adjustedContentInset), - "enabled": String(scroll.isScrollEnabled), - "pan": String(scroll.panGestureRecognizer.state.rawValue) - ]) - } - for child in view.subviews { result += scrolls(child, depth: depth + 1) } - return result - } - func hit(_ point: CGPoint) -> [String] { - var view = window.hitTest(point, with: nil) - var result = [String(describing: point)] - while let current = view, result.count < 20 { - result.append(String(describing: type(of: current))) - view = current.superview - } - return result - } - let metadata: [String: Any] = [ - "scrolls": scrolls(window, depth: 0), - "leadingHit": hit(CGPoint(x: 36, y: 590.5)), - "centreHit": hit(CGPoint(x: window.bounds.midX, y: 590.5)) - ] - guard let data = try? JSONSerialization.data(withJSONObject: metadata, options: [.sortedKeys]) else { return "Geometry unavailable" } - return String(decoding: data, as: UTF8.self) - } - set { super.accessibilityValue = newValue } - } -} -#endif diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index 8702617..6409516 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -2,25 +2,24 @@ import XCTest final class MemberActionsFlowTests: XCTestCase { override func setUp() { continueAfterFailure = false; XCUIDevice.shared.orientation = .portrait } + private func activate(_ element: XCUIElement) { + if element.identifier == "submit-request" { capture("Request review before submission") } + element.tap() + } private func tap(_ element: XCUIElement, in app: XCUIApplication) { + var reviewDown = false var previousReviewFrame: CGRect? var capturedReviewStall = false for attempt in 0..<24 { let scroll = containingScroll(element, app) if element.exists { let insideScroll = contains(element, scroll) - if element.isHittable && !insideScroll { element.tap(); return } + if element.isHittable && !insideScroll { activate(element); return } let frame = element.frame let viewport = visibleContent(scroll, app) let visible = frame.intersection(viewport) if scroll.identifier == "request-review-scroll" { - if attempt == 0 { - let diagnostics = app.descendants(matching: .any)["review-scroll-diagnostics"].firstMatch - print("Initial native review metadata: \(diagnostics.exists ? String(describing: diagnostics.value) : "probe unavailable")") - } if !capturedReviewStall, previousReviewFrame == frame { - let diagnostics = app.descendants(matching: .any)["review-scroll-diagnostics"].firstMatch - print("Native review metadata: \(diagnostics.exists ? String(describing: diagnostics.value) : "probe unavailable")") capture("Review scroll stalled before " + element.identifier) capturedReviewStall = true } @@ -30,12 +29,12 @@ final class MemberActionsFlowTests: XCTestCase { // A SwiftUI Toggle can expose a label rectangle that differs from its // native UISwitch activation point. Let XCTest activate a visible switch. if element.elementType == .switch, element.isHittable, - !visible.isNull, visible.width > 4, visible.height > 4 { element.tap(); return } + !visible.isNull, visible.width > 4, visible.height > 4 { activate(element); return } if element.isHittable, !visible.isNull, visible.width > 4, visible.height > 4, viewport.insetBy(dx: 2, dy: 2).contains(CGPoint(x: frame.midX, y: frame.midY)) { // Use the native activation point. A wide Toggle's label centre is not // its switch thumb; coordinates are only needed for clipped text fields. - element.tap(); return + activate(element); return } if (element.elementType == .textField || element.elementType == .secureTextField), element.isHittable, !visible.isNull, visible.width > 8, visible.height > 8 { @@ -45,8 +44,21 @@ final class MemberActionsFlowTests: XCTestCase { // rotation. Reversing on their midpoint oscillated around hidden controls. // Sweep consistently in each direction; activation still requires a real // visible, hittable target. New screens normally start near their top. - drag(scroll, app, target: element, down: attempt >= 12) - } else { drag(scroll, app, target: element, down: attempt >= 12) } + if scroll.identifier == "request-review-scroll" { + // Native geometry proves this target remains in the same modal. + // Some drags can make no progress; do not reverse a resumed + // search merely because half the gesture budget elapsed. + if frame.minY.isFinite, frame.maxY.isFinite, frame.height > 0 { + if frame.maxY <= viewport.minY { reviewDown = true } + else if frame.minY >= viewport.maxY { reviewDown = false } + else if frame.minY < viewport.minY { reviewDown = true } + else if frame.maxY > viewport.maxY { reviewDown = false } + } + drag(scroll, app, target: element, down: reviewDown) + } else { drag(scroll, app, target: element, down: attempt >= 12) } + } else { + drag(scroll, app, target: element, down: scroll.identifier == "request-review-scroll" ? reviewDown : attempt >= 12) + } } let target = element.exists ? (element.identifier.isEmpty ? element.label : element.identifier) : "missing control" capture("Unreachable action " + target) @@ -117,10 +129,6 @@ final class MemberActionsFlowTests: XCTestCase { } let start = CGPoint(x: x, y: viewport.midY + (down ? -distance : distance) / 2) let end = CGPoint(x: x, y: viewport.midY + (down ? distance : -distance) / 2) - if scroll.identifier == "request-review-scroll" { - let purpose = app.descendants(matching: .any)["request-purpose"].firstMatch - print("Review drag \(start) -> \(end), scroll \(scroll.frame), purpose \(purpose.exists ? String(describing: purpose.frame) : "absent")") - } app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: start.x - app.frame.minX, dy: start.y - app.frame.minY)) .press(forDuration: 0.05, thenDragTo: app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: end.x - app.frame.minX, dy: end.y - app.frame.minY)), diff --git a/SpaceWorksUITests/NativeUIActions.swift b/SpaceWorksUITests/NativeUIActions.swift index f1f2a7a..3b95e4a 100644 --- a/SpaceWorksUITests/NativeUIActions.swift +++ b/SpaceWorksUITests/NativeUIActions.swift @@ -3,6 +3,7 @@ import XCTest /// Native bars can overlap the accessibility scroll frame. Keep gestures in visible content. enum NativeUIActions { static func tap(_ element: XCUIElement, in app: XCUIApplication) { + var reviewDown = false for attempt in 0..<24 { let scroll = containingScroll(element, app) if element.exists { @@ -30,8 +31,21 @@ enum NativeUIActions { // rotation. Reversing on their midpoint oscillated around hidden controls. // Sweep consistently in each direction; activation still requires a real // visible, hittable target. New screens normally start near their top. - drag(scroll, app, target: element, down: attempt >= 12) - } else { drag(scroll, app, target: element, down: attempt >= 12) } + if scroll.identifier == "request-review-scroll" { + // Native geometry proves this target remains in the same modal. + // Some drags can make no progress; do not reverse a resumed + // search merely because half the gesture budget elapsed. + if frame.minY.isFinite, frame.maxY.isFinite, frame.height > 0 { + if frame.maxY <= viewport.minY { reviewDown = true } + else if frame.minY >= viewport.maxY { reviewDown = false } + else if frame.minY < viewport.minY { reviewDown = true } + else if frame.maxY > viewport.maxY { reviewDown = false } + } + drag(scroll, app, target: element, down: reviewDown) + } else { drag(scroll, app, target: element, down: attempt >= 12) } + } else { + drag(scroll, app, target: element, down: scroll.identifier == "request-review-scroll" ? reviewDown : attempt >= 12) + } } XCTFail("Action must be reachable after scrolling visible content.") } @@ -100,10 +114,6 @@ enum NativeUIActions { } let start = CGPoint(x: x, y: viewport.midY + (down ? -distance : distance) / 2) let end = CGPoint(x: x, y: viewport.midY + (down ? distance : -distance) / 2) - if scroll.identifier == "request-review-scroll" { - let purpose = app.descendants(matching: .any)["request-purpose"].firstMatch - print("Review drag \(start) -> \(end), scroll \(scroll.frame), purpose \(purpose.exists ? String(describing: purpose.frame) : "absent")") - } app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: start.x - app.frame.minX, dy: start.y - app.frame.minY)) .press(forDuration: 0.05, thenDragTo: app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: end.x - app.frame.minX, dy: end.y - app.frame.minY)), From a989989029a3a1279ffbbc3168e6028388d5ffdb Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:51:45 +0530 Subject: [PATCH 31/98] perf(a4): schedule request countdown only when needed Co-Authored-By: Codex --- .github/workflows/ios.yml | 2 +- SpaceWorks/MemberActionViews.swift | 19 +++++++++++++------ docs/CHANGELOG.md | 7 +++++++ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 4ae086b..79f933f 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -13,7 +13,7 @@ jobs: runs-on: macos-15 env: DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer - timeout-minutes: 60 + timeout-minutes: 120 name: Build and test (${{ matrix.device }}) strategy: fail-fast: false diff --git a/SpaceWorks/MemberActionViews.swift b/SpaceWorks/MemberActionViews.swift index d968482..a93c465 100644 --- a/SpaceWorks/MemberActionViews.swift +++ b/SpaceWorks/MemberActionViews.swift @@ -39,12 +39,12 @@ struct RequestCartView: View { .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)).accessibilityIdentifier("request-purpose") Text("Up to 500 characters. Don't include sensitive information.").font(.caption).foregroundStyle(Color.swMuted) if let error = state.error { SWError(message: error) } - TimelineView(.periodic(from: .now, by: 1)) { tick in - if let deadline = state.retryAfter, tick.date < deadline { Text("Try again in \(max(1, Int(ceil(deadline.timeIntervalSince(tick.date)))))s.").foregroundStyle(Color.swMuted) } - Button(state.busy ? "Submitting…" : "Submit request") { - Task { await state.submit(); if state.receipt != nil { await completed() } } - }.buttonStyle(SWPrimary()).disabled(state.busy || state.lines.isEmpty || (state.retryAfter ?? .distantPast) > tick.date) - .accessibilityIdentifier("submit-request") + if state.retryAfter != nil { + TimelineView(.periodic(from: .now, by: 1)) { tick in + submission(at: tick.date) + } + } else { + submission(at: .now) } } } @@ -53,6 +53,13 @@ struct RequestCartView: View { .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close") { dismiss() }.frame(minHeight: 48).disabled(state.busy) } } }.interactiveDismissDisabled(state.busy) } + @ViewBuilder private func submission(at date: Date) -> some View { + if let deadline = state.retryAfter, date < deadline { Text("Try again in \(max(1, Int(ceil(deadline.timeIntervalSince(date)))))s.").foregroundStyle(Color.swMuted) } + Button(state.busy ? "Submitting…" : "Submit request") { + Task { await state.submit(); if state.receipt != nil { await completed() } } + }.buttonStyle(SWPrimary()).disabled(state.busy || state.lines.isEmpty || (state.retryAfter ?? .distantPast) > date) + .accessibilityIdentifier("submit-request") + } } struct RenewLoanView: View { diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 41bc0da..640398b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -33,6 +33,13 @@ waiter continues. Add installation-epoch checks before publishing foreground/res cannot restore protected UI after session invalidation. Preserve privacy and draft state across app backgrounding, and retain scope-aware notification navigation across phone/tablet layout changes. +Schedule the request-review countdown only when a Retry-After deadline exists; the normal review +uses identical static submission controls and keeps the same busy, empty-cart and deadline guards. +This removes needless scheduled work without claiming a proven cause for the phone scroll stall. +Allow 120 minutes for the complete native suite and unsigned physical-device Release compilation; +all tests, profiles, gestures and strict visibility assertions remain enabled. Native acceptance +is still pending after the tablet passed and the phone request-review journey failed. + Parity: Notifications / Enable notifications / Notifications are off for this device. / Open system settings / Open update. Local generation/capability/source checks pass; native state/API/UI tests and rendered phone/tablet evidence await the A4 compiler checkpoint. Real APNs delivery, server link files, From 8d7b036dc58b810ef76d9efca01185d94778ce02 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:36:16 +0530 Subject: [PATCH 32/98] feat(a5): add authoritative native payments Co-Authored-By: Codex --- .../Sources/SpaceWorksAPI/Networking.swift | 20 +- .../SpaceWorksAPI/PaymentOperations.swift | 57 +++ .../openapi-generator-config.yaml | 5 + SpaceWorks/AuthState.swift | 24 +- SpaceWorks/DemoMode.swift | 23 +- SpaceWorks/LiveMemberActionsRepository.swift | 10 +- SpaceWorks/LiveMemberRepository.swift | 7 +- SpaceWorks/MemberNavigation.swift | 2 +- SpaceWorks/MemberRoutes.swift | 7 + SpaceWorks/MemberViews.swift | 8 +- SpaceWorks/PaymentsDemo.swift | 67 +++ SpaceWorks/PaymentsLinkDestination.swift | 26 ++ SpaceWorks/PaymentsState.swift | 305 +++++++++++++ SpaceWorks/PaymentsViews.swift | 272 ++++++++++++ SpaceWorks/RootView.swift | 36 +- SpaceWorksTests/AuthTests.swift | 25 +- SpaceWorksTests/MemberNetworkingTests.swift | 2 +- SpaceWorksTests/PaymentsTests.swift | 401 ++++++++++++++++++ .../RepositoryOwnershipTests.swift | 101 +++++ .../MemberActionsFlowTests.swift | 23 +- SpaceWorksUITests/NativeUIActions.swift | 23 +- SpaceWorksUITests/PaymentsFlowTests.swift | 47 ++ docs/CHANGELOG.md | 50 ++- 23 files changed, 1499 insertions(+), 42 deletions(-) create mode 100644 Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PaymentOperations.swift create mode 100644 SpaceWorks/PaymentsDemo.swift create mode 100644 SpaceWorks/PaymentsLinkDestination.swift create mode 100644 SpaceWorks/PaymentsState.swift create mode 100644 SpaceWorks/PaymentsViews.swift create mode 100644 SpaceWorksTests/PaymentsTests.swift create mode 100644 SpaceWorksTests/RepositoryOwnershipTests.swift create mode 100644 SpaceWorksUITests/PaymentsFlowTests.swift diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift index b9e598f..1aeb4aa 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift @@ -55,6 +55,12 @@ public struct APIError: Error, LocalizedError, Sendable, Equatable { } } + public static func isCancellation(_ error: Error) -> Bool { + if error is CancellationError || (error as? URLError)?.code == .cancelled { return true } + if let error = error as? ClientError { return isCancellation(error.underlyingError) } + return false + } + public static func display(_ error: Error) -> String { if let error = error as? APIError { return error.errorDescription ?? error.detail } if let error = error as? ClientError { return display(error.underlyingError) } @@ -315,8 +321,18 @@ public final class SpaceWorksService: Sendable { _ = try result.ok.body.json } /// A task's makerspace header stays bound to its original repository across actor suspension. - public func withMakerspace(_ id: Int, operation: @Sendable () async throws -> T) async throws -> T { - let credentials = await refresh.snapshot() + public func withMakerspace(_ id: Int, expectedCredentials: CredentialSnapshot? = nil, + operation: @Sendable () async throws -> T) async throws -> T { + if let expectedCredentials { + guard await refresh.isSameInstallation(expectedCredentials) else { throw CancellationError() } + guard await refresh.isCurrent(expectedCredentials) else { + throw APIError(status: 401, code: "signed_out", detail: "Please sign in again.") + } + } + let credentials: CredentialSnapshot + if let existing = DeviceRequestScope.credentials { credentials = existing } + else if let expectedCredentials { credentials = expectedCredentials } + else { credentials = await refresh.snapshot() } return try await DeviceRequestScope.$credentials.withValue(credentials) { try await DeviceRequestScope.$makerspace.withValue(id, operation: operation) } diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PaymentOperations.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PaymentOperations.swift new file mode 100644 index 0000000..a6922c8 --- /dev/null +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PaymentOperations.swift @@ -0,0 +1,57 @@ +import Foundation +import OpenAPIRuntime + +public enum PaymentOperationFailure { + public static func isCancellation(_ error: Error) -> Bool { + if error is CancellationError || (error as? URLError)?.code == .cancelled { return true } + if let client = error as? ClientError { return isCancellation(client.underlyingError) } + return false + } +} + +/// A download grant authorizes a documented API operation; it is not an arbitrary browser URL. +public enum PaymentDownloadGrant { + public static func token(from value: String, documentID: Int, origin: ServerOrigin) throws -> String { + guard documentID > 0, value.utf8.count <= 8192, !value.contains("\\"), + !value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }), + let url = URL(string: value, relativeTo: origin.url)?.absoluteURL, + let parts = URLComponents(url: url, resolvingAgainstBaseURL: true), + parts.scheme == origin.url.scheme, parts.host?.lowercased() == origin.url.host?.lowercased(), + effectivePort(url) == effectivePort(origin.url), + parts.user == nil, parts.password == nil, parts.fragment == nil, + parts.percentEncodedPath == "/api/v1/payment-documents/\(documentID)/download", + let items = parts.queryItems, items.count == 1, items[0].name == "token", + let token = items[0].value, !token.isEmpty, token.utf8.count <= 4096, + !token.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { + throw APIError(status: 0, code: "invalid_document_grant", detail: "This document link is unavailable. Request a new link and try again.") + } + return token + } + private static func effectivePort(_ url: URL) -> Int { url.port ?? (url.scheme == "https" ? 443 : 80) } +} + +extension SpaceWorksService { + public func memberPayments(makerspaceID: Int) async throws -> [Components.Schemas.MemberPayment] { + try await client.api_v1_member_makerspaces_payments_list(.init(path: .init(makerspace_id: makerspaceID))).ok.body.json + } + public func memberPaymentIntent(makerspaceID: Int, paymentID: Int) async throws -> Components.Schemas.MobilePaymentIntentResponse { + try await client.api_v1_member_makerspaces_payments_mobile_intent_create( + .init(path: .init(makerspace_id: makerspaceID, payment_id: paymentID))).ok.body.json + } + public func memberPaymentDocuments(makerspaceID: Int, page: Int) async throws -> Components.Schemas.DocumentPage { + try await client.api_v1_member_makerspaces_payment_documents_retrieve( + .init(path: .init(makerspace_id: makerspaceID), query: .init(page: page))).ok.body.json + } + public func memberPaymentPDF(makerspaceID: Int, documentID: Int, origin: ServerOrigin) async throws -> Data { + let grant = try await client.api_v1_member_makerspaces_payment_documents_download_url_retrieve( + .init(path: .init(document_id: documentID, makerspace_id: makerspaceID))).ok.body.json + guard grant.expires_in > 0 else { + throw APIError(status: 0, code: "expired_document_grant", detail: "This document link expired. Try again to request a new link.") + } + let token = try PaymentDownloadGrant.token(from: grant.url, documentID: documentID, origin: origin) + // This operation requires the authenticated generated client even with the signed token. + let body = try await client.api_v1_payment_documents_download_retrieve( + .init(path: .init(document_id: documentID), query: .init(token: token))).ok.body.pdf + return try await Data(collecting: body, upTo: 25 * 1024 * 1024) + } +} diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml index 58ddc58..53b57cc 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml @@ -48,3 +48,8 @@ filter: - api_v1_bootstrap_retrieve - api_v1_integrations_push_devices_create - api_v1_integrations_push_devices_destroy + - api_v1_member_makerspaces_payments_list + - api_v1_member_makerspaces_payments_mobile_intent_create + - api_v1_member_makerspaces_payment_documents_retrieve + - api_v1_member_makerspaces_payment_documents_download_url_retrieve + - api_v1_payment_documents_download_retrieve diff --git a/SpaceWorks/AuthState.swift b/SpaceWorks/AuthState.swift index cfb6c93..cd6c131 100644 --- a/SpaceWorks/AuthState.swift +++ b/SpaceWorks/AuthState.swift @@ -25,6 +25,7 @@ final class AuthState { private let storage: any TokenStorage private var restored = false private var identityGeneration = 0 + private(set) var acceptedCredentials: CredentialSnapshot? private var identityFlight: Task? private var privateHolders: [UUID: () -> Void] = [:] private var mfaExpires: Date? @@ -250,6 +251,7 @@ final class AuthState { try await service.makerspace.select(selection, activeMemberships: ids) guard generation == identityGeneration, await service.refresh.isCurrent(credentials) else { throw CancellationError() } if let pushAccountID, pushAccountID != identity.id { detachNotifications() } + acceptedCredentials = credentials user = identity; selectedID = selection stage = identity.must_change_password ? .passwordChange : .signedIn if stage == .signedIn { configureNotifications(accountID: identity.id, credentials: credentials) } @@ -314,10 +316,10 @@ final class AuthState { private func configureNotifications(accountID: Int, credentials: CredentialSnapshot) { guard pushAccountID == nil, notifications == nil else { return } pushAccountID = accountID + let failureHandlers = repositoryFailureHandlers() let registration = PushRegistrationState(repository: LivePushRegistrationRepository(service: service, expectedCredentials: credentials), storage: PushRegistrationVault(origin: origin, accountID: accountID), configured: Brand.pushConfigured, - onUnauthorized: { [weak self] message in await self?.invalidateSession(message: message) }, - onForbidden: { [weak self] in await self?.foreground() }) + onUnauthorized: failureHandlers.unauthorized, onForbidden: failureHandlers.forbidden) let notificationState = NotificationSettingsState(registration: registration, environment: Brand.pushEnvironment, memberships: { [weak self] in guard let self, self.user?.id == accountID else { throw CancellationError() }; return try await self.freshMembershipIDs() }) notifications = notificationState @@ -344,6 +346,23 @@ final class AuthState { let cleanup = detachNotificationCleanup() await cleanup.state?.stop(authorizedDirectMembershipIDs: cleanup.ids) } + /// Capture failure ownership on MainActor before a repository can hop executors. + /// The callback rechecks this identity on MainActor, without an intervening await. + func repositoryFailureHandlers() -> (unauthorized: @Sendable (String) async -> Void, forbidden: @Sendable () async -> Void) { + let generation = identityGeneration; let accountID = user?.id + return ( + { [weak self] message in await self?.repositoryRefused(message, accountID: accountID, generation: generation) }, + { [weak self] in await self?.repositoryAuthorityChanged(accountID: accountID, generation: generation) } + ) + } + private func repositoryRefused(_ message: String, accountID: Int?, generation: Int) async { + guard let accountID, identityGeneration == generation, user?.id == accountID, acceptedCredentials != nil else { return } + await invalidateSession(message: message) + } + private func repositoryAuthorityChanged(accountID: Int?, generation: Int) async { + guard let accountID, identityGeneration == generation, user?.id == accountID, acceptedCredentials != nil else { return } + await foreground() + } func registerPrivateHolder(_ owner: UUID, clear: @escaping () -> Void) { privateHolders[owner] = clear } func unregisterPrivateHolder(_ owner: UUID) { privateHolders.removeValue(forKey: owner) } private func clearPrivateHolders() { @@ -353,6 +372,7 @@ final class AuthState { private func clearLocalIdentity() { identityGeneration += 1; identityFlight?.cancel(); identityFlight = nil; clearPrivateHolders() settings.removeObject(forKey: selectionKey) + acceptedCredentials = nil user = nil; selectedID = nil; mfa = nil; mfaExpires = nil; enrollment = nil; recoveryCodes = [] username = ""; password = ""; code = ""; stage = .signIn } diff --git a/SpaceWorks/DemoMode.swift b/SpaceWorks/DemoMode.swift index 04109c6..30f471f 100644 --- a/SpaceWorks/DemoMode.swift +++ b/SpaceWorks/DemoMode.swift @@ -21,6 +21,7 @@ final class DemoSession { var retryAfter: Date? var selectedSpace = 0 var notificationState = DemoNotificationState() + @ObservationIgnored private var paymentRepositories: [Int: DemoPaymentRepository] = [:] var actionScenario = DemoMemberActionsRepository.Scenario.success var profileDisplayName: String? var memberScenario = DemoMemberRepository.Scenario.populated @@ -58,7 +59,11 @@ final class DemoSession { code = ""; error = nil; stage = scenario == .enrollment ? .recovery : .makerspace } func acceptDemoProvider() { error = nil; stage = scenario == .enrollment || scenario == .mfa ? .mfa : .makerspace } - func reset() { notificationState = DemoNotificationState(); username = ""; password = ""; code = ""; error = nil; retryAfter = nil; selectedSpace = 0; profileDisplayName = nil; actionScenario = .success; stage = .welcome } + func paymentRepository(for makerspaceID: Int) -> DemoPaymentRepository { + if let repository = paymentRepositories[makerspaceID] { return repository } + let repository = DemoPaymentRepository(); paymentRepositories[makerspaceID] = repository; return repository + } + func reset() { paymentRepositories = [:]; notificationState = DemoNotificationState(); username = ""; password = ""; code = ""; error = nil; retryAfter = nil; selectedSpace = 0; profileDisplayName = nil; actionScenario = .success; stage = .welcome } } enum DemoProvider: String, CaseIterable, Identifiable { @@ -253,10 +258,12 @@ struct DemoShell: View { @State private var cart: RequestCartState @State private var drafts: MemberActionDrafts @State private var capabilities: MemberCapabilities + @State private var payments: PaymentsState @State private var actions: DemoMemberActionsRepository @State private var columns = NavigationSplitViewVisibility.automatic init(session: DemoSession, linkRouter: MobileLinkRouter) { self.session = session; self.linkRouter = linkRouter + _payments = State(initialValue: PaymentsState(repository: session.paymentRepository(for: session.selectedSpace + 7), isDemo: true, sleep: { _ in })) let store = DemoMemberStore() let actions = DemoMemberActionsRepository(store: store, scenario: session.actionScenario, name: session.name) _actions = State(initialValue: actions) @@ -293,10 +300,12 @@ struct DemoShell: View { }.onChange(of: geometry.size.width, initial: true) { _, width in columns = width >= 1000 ? .all : .detailOnly } - }.task { async let config: Void = capabilities.load(); await member.loadUnreadCount(); await config }.onDisappear { member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate() } + }.task { async let config: Void = capabilities.load(); await member.loadUnreadCount(); await config }.onDisappear { member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate() } .task(id: linkRouter.approved?.id) { if let approved = linkRouter.approved, approved.link.makerspaceID == session.selectedSpace + 7 { - await MobileLinkDestination.open(approved, router: linkRouter, member: member, navigation: navigation, drafts: drafts) + await MobileLinkDestination.open(approved, router: linkRouter, member: member, navigation: navigation, drafts: drafts, payment: { id in + try await PaymentsLinkDestination.open(id, state: payments, navigation: navigation) { linkRouter.isCurrent(approved) } + }) } } .onChange(of: session.actionScenario) { _, scenario in Task { await actions.preview(scenario) } } @@ -304,9 +313,9 @@ struct DemoShell: View { } private func destination(_ tab: AppTab) -> some View { NavigationStack(path: navigation.path(tab)) { - DemoDestination(session: session, member: member, tab: tab, actions: actions, cart: cart, navigation: navigation, capabilities: capabilities, drafts: drafts, linkRouter: linkRouter) + DemoDestination(session: session, member: member, tab: tab, actions: actions, cart: cart, navigation: navigation, capabilities: capabilities, drafts: drafts, payments: payments, linkRouter: linkRouter) .navigationDestination(for: MemberRoute.self) { route in - MemberRouteView(route: route, member: member, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, isDemo: true) { + MemberRouteView(route: route, member: member, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, isDemo: true, payments: payments) { if let profile = try? await actions.profile() { session.profileDisplayName = profile.display_name } } } @@ -322,10 +331,11 @@ struct DemoDestination: View { let navigation: MemberNavigation let capabilities: MemberCapabilities let drafts: MemberActionDrafts + let payments: PaymentsState let linkRouter: MobileLinkRouter var body: some View { Group { - if tab != .profile { MemberContent(state: member, tab: tab, name: session.name, selectTab: { navigation.selected = $0 }, actions: actions, cart: cart, navigation: navigation, drafts: drafts, openLink: { text in + if tab != .profile { MemberContent(state: member, tab: tab, name: session.name, selectTab: { navigation.selected = $0 }, actions: actions, cart: cart, navigation: navigation, drafts: drafts, paymentsAvailable: true, openLink: { text in guard let origin = try? ServerOrigin("https://demo.spaceworks.invalid") else { return } if let link = MobileLink.inbox(text, origin: origin) { linkRouter.receive(link) } else if let legacy = MobileLink.legacy(text, origin: origin) { linkRouter.receive(legacy) } @@ -341,6 +351,7 @@ struct DemoDestination: View { if let error = capabilities.error { SWError(message: error); Button("Retry account options") { Task { await capabilities.load() } }.frame(minHeight: 48) } if capabilities.selfService { NavigationLink("Edit profile", value: MemberRoute.contact).frame(minHeight: 48) } if capabilities.preferences { NavigationLink("Communication preferences", value: MemberRoute.preferences).frame(minHeight: 48) } + NavigationLink("Payments", value: MemberRoute.payments).frame(minHeight: 48).accessibilityIdentifier("payments-entry") NavigationLink("Notifications") { DemoNotificationsView(state: session.notificationState, makerspaceID: session.selectedSpace + 7) { link in let path: String diff --git a/SpaceWorks/LiveMemberActionsRepository.swift b/SpaceWorks/LiveMemberActionsRepository.swift index 318cfa4..9228ed4 100644 --- a/SpaceWorks/LiveMemberActionsRepository.swift +++ b/SpaceWorks/LiveMemberActionsRepository.swift @@ -3,15 +3,19 @@ import SpaceWorksAPI struct LiveMemberActionsRepository: MemberActionsRepository { let service: SpaceWorksService + let expectedCredentials: CredentialSnapshot? let id: Int let slug: String var onUnauthorized: @Sendable (String) async -> Void = { _ in } var onForbidden: @Sendable () async -> Void = {} private func checked(_ work: @Sendable () async throws -> T) async throws -> T { - do { return try await service.withMakerspace(id, operation: work) } + guard let expectedCredentials, await service.refresh.isSameInstallation(expectedCredentials) else { throw CancellationError() } + do { return try await service.withMakerspace(id, expectedCredentials: expectedCredentials, operation: work) } catch { - let missingTokens = await service.refresh.current() == nil - if APIError.status(error) == 401 || missingTokens { await onUnauthorized(APIError.display(error)) } + if Task.isCancelled || APIError.isCancellation(error) { throw error } + guard await service.refresh.isSameInstallation(expectedCredentials) else { throw CancellationError() } + let empty = await service.refresh.current() == nil + if APIError.status(error) == 401 || empty { await onUnauthorized(APIError.display(error)) } else if APIError.status(error) == 403 { await onForbidden() } throw error } diff --git a/SpaceWorks/LiveMemberRepository.swift b/SpaceWorks/LiveMemberRepository.swift index 693c71e..3c27425 100644 --- a/SpaceWorks/LiveMemberRepository.swift +++ b/SpaceWorks/LiveMemberRepository.swift @@ -3,13 +3,18 @@ import SpaceWorksAPI struct LiveMemberRepository: MemberRepository { let service: SpaceWorksService + let expectedCredentials: CredentialSnapshot? let origin: ServerOrigin let id: Int let slug: String var onUnauthorized: @Sendable (String) async -> Void = { _ in } var onForbidden: @Sendable () async -> Void = {} private func checked(_ work: @Sendable () async throws -> T) async throws -> T { - do { return try await service.withMakerspace(id, operation: work) } catch { + guard let expectedCredentials, await service.refresh.isSameInstallation(expectedCredentials) else { throw CancellationError() } + do { return try await service.withMakerspace(id, expectedCredentials: expectedCredentials, operation: work) } + catch { + if Task.isCancelled || APIError.isCancellation(error) { throw error } + guard await service.refresh.isSameInstallation(expectedCredentials) else { throw CancellationError() } let empty = await service.refresh.current() == nil if APIError.status(error) == 401 || empty { await onUnauthorized(APIError.display(error)) } else if APIError.status(error) == 403 { await onForbidden() } diff --git a/SpaceWorks/MemberNavigation.swift b/SpaceWorks/MemberNavigation.swift index 77d3ac6..7a1a535 100644 --- a/SpaceWorks/MemberNavigation.swift +++ b/SpaceWorks/MemberNavigation.swift @@ -1,7 +1,7 @@ import SwiftUI enum MemberRoute: Hashable { - case product(Int), spaces, space(String), event(String), booking(String), registration(Int), contact, preferences + case product(Int), spaces, space(String), event(String), booking(String), registration(Int), contact, preferences, payments, payment(Int) } /// Memory-only navigation belongs to one authenticated account/makerspace, outside adaptive branches. diff --git a/SpaceWorks/MemberRoutes.swift b/SpaceWorks/MemberRoutes.swift index e16e44d..5eaf9cb 100644 --- a/SpaceWorks/MemberRoutes.swift +++ b/SpaceWorks/MemberRoutes.swift @@ -11,6 +11,7 @@ struct MemberRouteView: View { let navigation: MemberNavigation let capabilities: MemberCapabilities let isDemo: Bool + var payments: PaymentsState? = nil var profileSaved: () async -> Void = {} var body: some View { switch route { @@ -26,6 +27,12 @@ struct MemberRouteView: View { } else { missing("Event unavailable", load: member.eventsLoad) { await member.loadEvents() }.task { if member.eventsLoad == .idle { await member.loadEvents() } } } case .booking(let token): BookingDestination(member: member, repository: actions, token: token, isDemo: isDemo) case .registration(let id): RegistrationDestination(member: member, repository: actions, id: id, isDemo: isDemo, drafts: drafts) + case .payments: + if let payments { PaymentsView(state: payments) } + else { disabledFeature("Payments are unavailable. Try again later.") } + case .payment(let id): + if let payments { PaymentDetailView(state: payments, paymentID: id) } + else { disabledFeature("Payments are unavailable. Try again later.") } case .contact: if capabilities.selfService { ProfileEditorView(repository: actions, isDemo: isDemo, drafts: drafts, saved: profileSaved) } else { disabledFeature("Account self-service is disabled for this makerspace.") } diff --git a/SpaceWorks/MemberViews.swift b/SpaceWorks/MemberViews.swift index a32b2af..9116b4f 100644 --- a/SpaceWorks/MemberViews.swift +++ b/SpaceWorks/MemberViews.swift @@ -11,6 +11,7 @@ struct MemberContent: View { var cart: RequestCartState? = nil var navigation: MemberNavigation? = nil var drafts: MemberActionDrafts? = nil + var paymentsAvailable = false var openLink: ((String) -> Void)? = nil @State private var showCard = false @State private var renewal: Components.Schemas.MemberLoanActivity? @@ -37,7 +38,10 @@ struct MemberContent: View { } LazyVGrid(columns: textSize.isAccessibilitySize ? [GridItem(.flexible(), alignment: .topLeading)] : [GridItem(.adaptive(minimum: 320), spacing: 24, alignment: .topLeading)], alignment: .leading, spacing: 24) { loans - if tab == .activity { requests; registrations } + if tab == .activity { + if paymentsAvailable { SWCard { NavigationLink("Payments", value: MemberRoute.payments).frame(minHeight: 48) } } + requests; registrations + } bookings events if tab == .home { @@ -161,6 +165,7 @@ struct MemberContent: View { private var dues: some View { SWCard { Label("Dues", systemImage: "creditcard").font(.title2.bold()) + if paymentsAvailable { NavigationLink("Payments", value: MemberRoute.payments).frame(minHeight: 48).accessibilityIdentifier("payments-home-entry") } if state.activityLoad == .loading || state.activityLoad == .idle { LoadFeedback(load: state.activityLoad, retryAfter: state.deadline("activity")) { await state.loadActivity() } } else { @@ -340,6 +345,7 @@ struct InventoryDetail: View { struct MemberInbox: View { @Bindable var state: MemberState + var paymentsAvailable = false var openLink: ((String) -> Void)? = nil var body: some View { ScrollView { diff --git a/SpaceWorks/PaymentsDemo.swift b/SpaceWorks/PaymentsDemo.swift new file mode 100644 index 0000000..907e383 --- /dev/null +++ b/SpaceWorks/PaymentsDemo.swift @@ -0,0 +1,67 @@ +#if DEBUG +import Foundation +import SpaceWorksAPI +import UIKit + +enum DemoPaymentScenario: Equatable { + case paid, pending, failed, cancelled + var outcome: NativePaymentOutcome { + switch self { + case .paid, .pending: return .awaitingServerConfirmation + case .failed: return .failed("Demo checkout failed. No money moved.") + case .cancelled: return .cancelled + } + } +} +protocol DemoPaymentControlling: PaymentRepository { + func simulate(paymentID: Int, scenario: DemoPaymentScenario) async +} + +/// Isolated sample records. This type and its fixtures do not exist in Release builds. +actor DemoPaymentRepository: DemoPaymentControlling { + private var confirmed = false + func payments() async throws -> [Components.Schemas.MemberPayment] { + let first = try fixture(id: 501, label: "Sample equipment deposit", amount: "250.00", status: confirmed ? "paid_online" : "pending") + let second = try fixture(id: 502, label: "Sample workshop booking", amount: "150.00", status: "paid_offline") + return [first, second] + } + func intent(paymentID: Int) async throws -> Components.Schemas.MobilePaymentIntentResponse { + throw APIError(status: 0, code: "demo_no_sdk", detail: "Demo mode never opens a payment provider.") + } + func simulate(paymentID: Int, scenario: DemoPaymentScenario) async { + guard paymentID == 501 else { return } + confirmed = scenario == .paid + } + func documents(page: Int) async throws -> Components.Schemas.DocumentPage { + let receipt = #"{"id":902,"payment":502,"number":"DEMO-RECEIPT-002","kind":"receipt","issued_at":"2026-10-16T09:00:00Z","money_at":"2026-10-16T09:00:00Z","amount":"150.00","currency":"inr"}"# + let newReceipt = #"{"id":901,"payment":501,"number":"DEMO-RECEIPT-001","kind":"receipt","issued_at":"2026-10-16T09:00:00Z","money_at":"2026-10-16T09:00:00Z","amount":"250.00","currency":"inr"}"# + let results = page == 1 ? (confirmed ? "\(newReceipt),\(receipt)" : receipt) : "" + return try decode("{\"count\":\(confirmed ? 2 : 1),\"next\":null,\"previous\":null,\"results\":[\(results)]}") + } + func documentPDF(documentID: Int) async throws -> Data { + guard documentID == 902 || (confirmed && documentID == 901) else { + throw APIError(status: 404, code: "not_found", detail: "Demo document unavailable.") + } + return await MainActor.run { + let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 595, height: 842)) + return renderer.pdfData { context in + context.beginPage() + let text = "SPACE WORKS\n\nDEMO RECEIPT · SAMPLE DATA\n\nNo money moved.\nThis document previews the receipt flow." + let attributes: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 18)] + text.draw(in: CGRect(x: 48, y: 64, width: 499, height: 500), withAttributes: attributes) + } + } + } + private func fixture(id: Int, label: String, amount: String, status: String) throws -> Components.Schemas.MemberPayment { + try decode(""" + {"id":\(id),"subject_type":"booking","subject_label":"\(label)","status":"\(status)","amount":"\(amount)", + "currency":"inr","due_at":null,"due_date":"2026-10-20","due_state":"upcoming","checkout_url":"", + "online_payment_available":true,"settlement":null,"refunded_amount":0,"refunds":[],"created_at":"2026-10-16T09:00:00Z"} + """) + } + private func decode(_ json: String) throws -> T { + let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(T.self, from: Data(json.utf8)) + } +} +#endif diff --git a/SpaceWorks/PaymentsLinkDestination.swift b/SpaceWorks/PaymentsLinkDestination.swift new file mode 100644 index 0000000..9572c32 --- /dev/null +++ b/SpaceWorks/PaymentsLinkDestination.swift @@ -0,0 +1,26 @@ +import Foundation +import SpaceWorksAPI + +/// Links use a fresh list response; a stale cached charge can never authorize navigation. +@MainActor enum PaymentsLinkDestination { + static func open(_ id: Int?, state: PaymentsState, navigation: MemberNavigation, + isCurrent: () -> Bool) async throws { + guard isCurrent() else { throw CancellationError() } + guard state.canRequest, !state.busy else { + throw APIError(status: state.retryAfter > Date() ? 429 : 0, code: "payment_link_wait", + detail: "Payments are still loading. Try again in a moment.", retryAfter: state.retryAfter > Date() ? state.retryAfter : nil) + } + await state.refresh() + guard isCurrent() else { throw CancellationError() } + switch state.load { + case .ready: + if let id { + guard state.record(id) != nil else { throw APIError(status: 404, code: "payment_link_missing", detail: "This charge is no longer available. Refresh your payments.") } + navigation.open(.payment(id), tab: .profile) + } else { navigation.open(.payments, tab: .profile) } + case .failed(let message): + throw APIError(status: state.retryAfter > Date() ? 429 : 0, code: "payment_link_failed", detail: message, retryAfter: state.retryAfter > Date() ? state.retryAfter : nil) + default: throw APIError(status: 0, code: "payment_link_wait", detail: "Payments are still loading. Try again in a moment.") + } + } +} diff --git a/SpaceWorks/PaymentsState.swift b/SpaceWorks/PaymentsState.swift new file mode 100644 index 0000000..f5c4024 --- /dev/null +++ b/SpaceWorks/PaymentsState.swift @@ -0,0 +1,305 @@ +import Foundation +import Observation +import SpaceWorksAPI + +protocol PaymentRepository: Sendable { + func payments() async throws -> [Components.Schemas.MemberPayment] + func intent(paymentID: Int) async throws -> Components.Schemas.MobilePaymentIntentResponse + func documents(page: Int) async throws -> Components.Schemas.DocumentPage + func documentPDF(documentID: Int) async throws -> Data +} + +struct LivePaymentRepository: PaymentRepository { + let service: SpaceWorksService + let expectedCredentials: CredentialSnapshot? + let makerspaceID: Int + let origin: ServerOrigin + var onUnauthorized: @Sendable (String) async -> Void = { _ in } + var onForbidden: @Sendable () async -> Void = {} + private func checked(_ work: @Sendable () async throws -> T) async throws -> T { + guard let expectedCredentials, await service.refresh.isSameInstallation(expectedCredentials) else { throw CancellationError() } + do { return try await service.withMakerspace(makerspaceID, expectedCredentials: expectedCredentials, operation: work) } + catch { + if Task.isCancelled || APIError.isCancellation(error) { throw error } + guard await service.refresh.isSameInstallation(expectedCredentials) else { throw CancellationError() } + let empty = await service.refresh.current() == nil + if APIError.status(error) == 401 || empty { await onUnauthorized(APIError.display(error)) } + else if APIError.status(error) == 403 { await onForbidden() } + throw error + } + } + func payments() async throws -> [Components.Schemas.MemberPayment] { + try await checked { try await service.memberPayments(makerspaceID: makerspaceID) } + } + func intent(paymentID: Int) async throws -> Components.Schemas.MobilePaymentIntentResponse { + try await checked { try await service.memberPaymentIntent(makerspaceID: makerspaceID, paymentID: paymentID) } + } + func documents(page: Int) async throws -> Components.Schemas.DocumentPage { + let result = try await checked { try await service.memberPaymentDocuments(makerspaceID: makerspaceID, page: page) } + if let next = try origin.nextPage(result.next), next.path != "/api/v1/member/makerspaces/\(makerspaceID)/payment-documents" { + throw APIError(status: 0, code: "invalid_next_page", detail: "The next document page is unavailable. Refresh and try again.") + } + return result + } + func documentPDF(documentID: Int) async throws -> Data { + try await checked { try await service.memberPaymentPDF(makerspaceID: makerspaceID, documentID: documentID, origin: origin) } + } +} + +@MainActor @Observable +final class PaymentsState { + enum Load: Equatable { case idle, loading, ready, failed(String) } + enum Checkout: Equatable { + case idle, preparing(Int), presenting(Int), checking(Int), awaiting(Int), completed(Int), stopped(Int, String) + } + let isDemo: Bool + private let repository: any PaymentRepository + private let sleep: @Sendable (UInt64) async throws -> Void + private let onUnauthorized: () -> Void + private let cancelSDK: () -> Void + private var cancelPresentation: (() -> Void)? + private var active = true + private var listGeneration = 0 + private var documentGeneration = 0 + private var previewGeneration = 0 + private var nextDocumentPage: Int? + private var checkoutAttempt: UUID? + @ObservationIgnored private var cooldownTask: Task? + private(set) var retryAfter = Date.distantPast + private(set) var records: [Components.Schemas.MemberPayment] = [] + private(set) var documents: [Components.Schemas.PaymentDocument] = [] + private(set) var load = Load.idle + private(set) var documentsLoad = Load.idle + private(set) var checkout = Checkout.idle + private(set) var reconciliationError: String? + private var throttleMessage: String? + private var needsFreshStatus = Set() + var documentsHaveMore: Bool { nextDocumentPage != nil } + private(set) var documentBusy = false + private(set) var documentError: String? + var preview: NativeDocumentFile? + + init(repository: any PaymentRepository, isDemo: Bool = false, + sleep: @escaping @Sendable (UInt64) async throws -> Void = { try await Task.sleep(nanoseconds: $0) }, + onUnauthorized: @escaping () -> Void = {}, cancelSDK: @escaping () -> Void = {}) { + self.repository = repository; self.isDemo = isDemo; self.sleep = sleep + self.onUnauthorized = onUnauthorized; self.cancelSDK = cancelSDK + } + var busy: Bool { + switch checkout { case .preparing, .presenting, .checking: return true; default: return false } + } + var canRequest: Bool { active && Date() >= retryAfter } + func record(_ id: Int) -> Components.Schemas.MemberPayment? { records.first { $0.id == id } } + func requiresStatusRefresh(_ id: Int) -> Bool { needsFreshStatus.contains(id) } + func canPay(_ row: Components.Schemas.MemberPayment) -> Bool { + canRequest && !busy && !needsFreshStatus.contains(row.id) && row.status?.rawValue == "pending" && row.online_payment_available && checkout != .awaiting(row.id) + } + func invalidate() { + active = false; listGeneration += 1; documentGeneration += 1; previewGeneration += 1 + cooldownTask?.cancel(); cooldownTask = nil + checkoutAttempt = nil; cancelSDK(); cancelPresentation?(); cancelPresentation = nil + records = []; documents = []; preview = nil; nextDocumentPage = nil; needsFreshStatus = [] + documentBusy = false; documentError = nil; reconciliationError = nil; throttleMessage = nil; checkout = .idle + } + func registerPresentationReset(_ reset: @escaping () -> Void) { + guard active else { reset(); return } + cancelPresentation?() + cancelPresentation = reset + } + #if DEBUG + func simulateDemo(paymentID: Int, scenario: DemoPaymentScenario) async { + guard active, isDemo, !busy, let demo = repository as? any DemoPaymentControlling, + let row = record(paymentID), canPay(row) else { return } + let attempt = UUID(); checkoutAttempt = attempt; checkout = .presenting(paymentID) + await demo.simulate(paymentID: paymentID, scenario: scenario) + await finishSDK(scenario.outcome, attempt: attempt, paymentID: paymentID) + } + func simulateDelayedConfirmation(paymentID: Int) async { + guard active, isDemo, !busy, checkout == .awaiting(paymentID), + let demo = repository as? any DemoPaymentControlling else { return } + await demo.simulate(paymentID: paymentID, scenario: .paid) + await checkStatus(paymentID: paymentID) + } + #endif + func refresh() async { + guard canRequest, !busy else { return } + listGeneration += 1; let generation = listGeneration + load = .loading + do { + let result = try await repository.payments() + guard active, generation == listGeneration else { return } + records = result; load = .ready; needsFreshStatus.subtract(result.map(\.id)) + if reconcileVisibleCheckout() { await loadDocuments() } + } catch { + guard active, generation == listGeneration else { return } + failAuthorization(error) + if active { load = .failed(APIError.display(error)) } + } + } + /// Fresh server data and a fresh documented intent precede every explicit SDK launch. + func prepareCheckout(paymentID: Int) async -> (UUID, Components.Schemas.MobilePaymentIntentResponse)? { + guard active, !isDemo, !busy, let initial = record(paymentID), canPay(initial) else { return nil } + listGeneration += 1 // An older pull-to-refresh cannot overwrite this checkout's fresh result. + let attempt = UUID(); checkoutAttempt = attempt; reconciliationError = nil; checkout = .preparing(paymentID) + do { + let current = try await repository.payments() + guard matches(attempt) else { return nil } + records = current + guard let row = record(paymentID), row.status?.rawValue == "pending", row.online_payment_available else { + checkoutAttempt = nil; checkout = .stopped(paymentID, "This payment is no longer available to pay online. Its latest status is shown above.") + return nil + } + guard canRequest else { throw throttledCheckout } + let intent = try await repository.intent(paymentID: paymentID) + guard matches(attempt) else { return nil } + guard canRequest else { throw throttledCheckout } + guard intent.payment_id == paymentID, !intent.client_secret.isEmpty, !intent.publishable_key.isEmpty else { + throw APIError(status: 0, code: "invalid_payment_setup", detail: "Payment setup is unavailable. Contact your makerspace.") + } + checkout = .presenting(paymentID) + return (attempt, intent) + } catch { + guard matches(attempt) else { return nil } + checkoutAttempt = nil; failAuthorization(error) + if active { checkout = .stopped(paymentID, APIError.display(error)) } + return nil + } + } + func finishSDK(_ outcome: NativePaymentOutcome, attempt: UUID, paymentID: Int) async { + guard matches(attempt), checkout == .presenting(paymentID) else { return } + needsFreshStatus.insert(paymentID); checkout = .checking(paymentID) + await reconcile(paymentID: paymentID, attempt: attempt, outcome: outcome) + } + func checkStatus(paymentID: Int) async { + guard canRequest, !busy else { return } + listGeneration += 1 + let attempt = UUID(); checkoutAttempt = attempt; reconciliationError = nil; checkout = .checking(paymentID) + await reconcile(paymentID: paymentID, attempt: attempt, outcome: .awaitingServerConfirmation) + } + private func reconcile(paymentID: Int, attempt: UUID, outcome: NativePaymentOutcome) async { + let tries = outcome == .awaitingServerConfirmation ? 4 : 1 + do { + for index in 0.. 0 { try await sleep(UInt64(index) * 1_000_000_000) } + guard matches(attempt) else { return } + guard canRequest else { + needsFreshStatus.insert(paymentID) + checkoutAttempt = nil; reconciliationError = throttleMessage ?? String(localized: "Too many requests. Try again later.") + checkout = outcome == .awaitingServerConfirmation ? .awaiting(paymentID) : .stopped(paymentID, reconciliationError!) + return + } + let result = try await repository.payments() + guard matches(attempt) else { return } + records = result; load = .ready; needsFreshStatus.remove(paymentID) + if let row = record(paymentID), ["paid_online", "paid_offline", "waived"].contains(row.status?.rawValue ?? "") { + checkoutAttempt = nil; checkout = .completed(paymentID) + await loadDocuments(); return + } + if record(paymentID)?.status?.rawValue == "canceled" { + checkoutAttempt = nil; checkout = .stopped(paymentID, "This payment was canceled."); return + } + } + guard matches(attempt) else { return } + checkoutAttempt = nil + switch outcome { + case .awaitingServerConfirmation: checkout = .awaiting(paymentID) + case .cancelled: checkout = .stopped(paymentID, "Checkout closed. Check the current payment status before trying again.") + case .failed(let message), .unavailable(let message): checkout = .stopped(paymentID, message) + } + } catch { + guard matches(attempt) else { return } + checkoutAttempt = nil; failAuthorization(error) + guard active, APIError.status(error) != 403 else { return } + needsFreshStatus.insert(paymentID); reconciliationError = APIError.display(error) + // An SDK-submitted payment is still unknown when a status read fails. Never + // offer another checkout until authoritative state confirms what happened. + checkout = outcome == .awaitingServerConfirmation ? .awaiting(paymentID) : .stopped(paymentID, String(localized: "Couldn't check this payment. Refresh its status before trying again.")) + } + } + func loadDocuments(more: Bool = false) async { + guard canRequest, !(more && (documentsLoad == .loading || !documentsHaveMore)) else { return } + documentGeneration += 1; let generation = documentGeneration + let page = more ? nextDocumentPage! : 1 + documentsLoad = .loading + do { + let result = try await repository.documents(page: page) + guard active, generation == documentGeneration else { return } + let next = try Self.nextDocumentPage(result.next, after: page) + var merged = more ? documents : [] + for document in result.results { + if let index = merged.firstIndex(where: { $0.id == document.id }) { merged[index] = document } + else { merged.append(document) } + } + documents = merged; nextDocumentPage = next; documentsLoad = .ready + } catch { + guard active, generation == documentGeneration else { return } + failAuthorization(error) + if active { documentsLoad = .failed(APIError.display(error)) } + } + } + func openDocument(_ document: Components.Schemas.PaymentDocument) async { + guard canRequest, !documentBusy, documents.contains(where: { $0.id == document.id }) else { return } + previewGeneration += 1; let generation = previewGeneration + preview = nil; documentError = nil; documentBusy = true + do { + let data = try await repository.documentPDF(documentID: document.id) + guard active, generation == previewGeneration else { return } + preview = try NativeFileTransfer.document(data: data, kind: .pdf, suggestedName: document.number) + } catch { + guard active, generation == previewGeneration else { return } + failAuthorization(error) + if active { documentError = (error as? NativeTransferError)?.errorDescription ?? APIError.display(error) } + } + if active, generation == previewGeneration { documentBusy = false } + } + private func matches(_ attempt: UUID) -> Bool { active && checkoutAttempt == attempt } + private func reconcileVisibleCheckout() -> Bool { + let id: Int + switch checkout { + case .awaiting(let tracked), .stopped(let tracked, _), .completed(let tracked): id = tracked + default: return false + } + guard let row = record(id) else { return false } + if ["paid_online", "paid_offline", "waived"].contains(row.status?.rawValue ?? "") { + checkout = .completed(id); reconciliationError = nil; return true + } + if row.status?.rawValue == "canceled" { checkout = .stopped(id, "This payment was canceled.") } + return false + } + private static func nextDocumentPage(_ value: String?, after page: Int) throws -> Int? { + guard let value else { return nil } + guard let parts = URLComponents(string: value), let items = parts.queryItems, + items.count == 1, items[0].name == "page", let raw = items[0].value, + !raw.isEmpty, raw.allSatisfy({ $0.isASCII && $0.isNumber }), let next = Int(raw), next > page else { + throw APIError(status: 0, code: "invalid_next_page", detail: "The next document page is unavailable. Refresh and try again.") + } + return next + } + private var throttledCheckout: APIError { APIError(status: 429, code: "payment_throttled", detail: throttleMessage ?? String(localized: "Too many requests. Try again later."), retryAfter: retryAfter) } + private func failAuthorization(_ error: Error) { + if APIError.status(error) == 429 { throttleMessage = APIError.display(error) } + if let deadline = APIError.retryDeadline(error), deadline > retryAfter { + retryAfter = deadline + cooldownTask?.cancel() + cooldownTask = Task { [weak self] in + while !Task.isCancelled { + guard let deadline = self?.retryAfter else { return } + let delay = deadline.timeIntervalSinceNow + if delay <= 0 { self?.retryAfter = .distantPast; return } + do { try await Task.sleep(nanoseconds: UInt64(min(60, delay) * 1_000_000_000)) } + catch { return } + } + } + } + if APIError.status(error) == 401 { invalidate(); onUnauthorized() } + else if APIError.status(error) == 403 { + // A concurrent document/read refusal also revokes any pending native checkout. + // Clear its attempt before resetting the SDK so a late callback cannot reconcile. + listGeneration += 1; documentGeneration += 1; previewGeneration += 1 + checkoutAttempt = nil; cancelSDK(); cancelPresentation?(); cancelPresentation = nil + checkout = .idle; reconciliationError = nil; documentBusy = false; documentError = nil + records = []; documents = []; preview = nil; nextDocumentPage = nil; needsFreshStatus = [] + load = .failed(APIError.display(error)); documentsLoad = .failed(APIError.display(error)) + } + } +} diff --git a/SpaceWorks/PaymentsViews.swift b/SpaceWorks/PaymentsViews.swift new file mode 100644 index 0000000..b6d3303 --- /dev/null +++ b/SpaceWorks/PaymentsViews.swift @@ -0,0 +1,272 @@ +import SwiftUI +import Observation +import SpaceWorksAPI +import UIKit + +struct PaymentsView: View { + @Bindable var state: PaymentsState + @State private var dueFilter: String? + private var visibleRecords: [Components.Schemas.MemberPayment] { + state.records.filter { dueFilter == nil || $0.due_state == dueFilter } + } + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 24) { + Text("Payments").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text("See your charges, payment status, and issued documents.").foregroundStyle(Color.swMuted) + Picker("Due date", selection: $dueFilter) { + Text("All charges").tag(String?.none) + Text("Overdue").tag(String?("overdue")) + Text("Due today").tag(String?("due_today")) + Text("Upcoming").tag(String?("upcoming")) + }.pickerStyle(.menu).frame(minHeight: 48).accessibilityIdentifier("payments-due-filter") + if state.isDemo { Label("Demo payments · sample data", systemImage: "info.circle").foregroundStyle(Color.swMuted) } + if !state.canRequest { + Text("Please wait until \(state.retryAfter.formatted(date: .omitted, time: .standard)) before trying again.").foregroundStyle(Color.swMuted) + } + if case .loading = state.load { ProgressView("Loading payments…") } + if case .failed(let message) = state.load { + SWError(message: message).accessibilityIdentifier("payments-error") + Button("Try again") { Task { await state.refresh() } }.frame(minHeight: 48).disabled(!state.canRequest) + } + if state.load == .ready && visibleRecords.isEmpty { + SWCard { + Label(dueFilter == nil ? "No charges here" : "No charges match this filter", systemImage: "checkmark.circle").font(.headline) + Text(dueFilter == nil ? "Your payments will appear here when your makerspace issues a charge." : "Choose another filter to see the other charges.").foregroundStyle(Color.swMuted) + } + } + ForEach(visibleRecords, id: \.id) { row in + NavigationLink { PaymentDetailView(state: state, paymentID: row.id) } label: { + SWCard { + Text(row.subject_label).font(.headline).foregroundStyle(Color.swInk) + PaymentMoney(amount: row.amount, currency: row.currency) + HStack(alignment: .top) { + Text(PaymentCopy.status(row.status?.rawValue)).foregroundStyle(Color.swMuted) + Spacer() + Image(systemName: "chevron.right").accessibilityHidden(true) + } + if let due = row.due_date, !due.isEmpty { Text("Due \(due)").font(.subheadline).foregroundStyle(Color.swMuted) } + } + }.buttonStyle(.plain).accessibilityIdentifier("payment-\(row.id)") + } + NavigationLink { PaymentDocumentsView(state: state) } label: { + Label("Receipts and credit notes", systemImage: "doc.text").frame(minHeight: 48) + }.accessibilityIdentifier("payment-documents") + }.padding(24).frame(maxWidth: 800).frame(maxWidth: .infinity) + }.background(Color.swBg).foregroundStyle(Color.swInk).tint(Color.swAccentInk) + .navigationTitle("Payments").navigationBarTitleDisplayMode(.inline) + .task { if state.load == .idle { await state.refresh() } } + .refreshable { await state.refresh() } + } +} + +struct PaymentDetailView: View { + @Bindable var state: PaymentsState + let paymentID: Int + @State private var presentation = PaymentPresentationContext() + #if DEBUG + @State private var demoChoice = false + #endif + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + if let row = state.record(paymentID) { + Text("Review payment").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text(row.subject_label).font(.title2.bold()) + SWCard { + PaymentMoney(amount: row.amount, currency: row.currency).font(.title.bold()) + Text(PaymentCopy.status(row.status?.rawValue)).font(.headline) + if let due = row.due_date, !due.isEmpty { Text("Due \(due)").foregroundStyle(Color.swMuted) } + Text("Charge #\(row.id)").font(.subheadline).foregroundStyle(Color.swMuted) + } + if let settlement = row.settlement?.value1 { + SWCard { + Text("Received at the desk").font(.headline) + Text(settlement.received_at, format: .dateTime.day().month().year().hour().minute()).foregroundStyle(Color.swMuted) + Text("Method: \(settlement.method)") + if !settlement.reference.isEmpty { Text("Reference: \(settlement.reference)").textSelection(.enabled) } + } + } + checkoutStatus + if !state.canRequest { + Text("Try again after \(state.retryAfter.formatted(date: .omitted, time: .standard)).").foregroundStyle(Color.swMuted) + } + if state.canPay(row) { + Text("Review the amount above. Your payment provider will open to complete checkout.").foregroundStyle(Color.swMuted) + Button("Continue to payment") { startCheckout() }.buttonStyle(SWPrimary()) + .accessibilityIdentifier("payment-continue") + } else if row.status?.rawValue == "pending", !row.online_payment_available { + Label("Online payment is unavailable. Ask your makerspace about paying at the desk.", systemImage: "info.circle") + .foregroundStyle(Color.swMuted) + } + Button("Check payment status") { Task { await state.checkStatus(paymentID: paymentID) } } + .frame(minHeight: 48).disabled(state.busy || !state.canRequest).accessibilityIdentifier("payment-check-status") + if !row.refunds.isEmpty { + Text("Refunds").font(.title2.bold()) + ForEach(Array(row.refunds.enumerated()), id: \.offset) { _, refund in + SWCard { + PaymentMoney(amount: refund.amount, currency: refund.currency) + Text(refund.status.replacingOccurrences(of: "_", with: " ").capitalized).font(.headline) + if !refund.reason.isEmpty { Text(refund.reason).foregroundStyle(Color.swMuted) } + if !refund.reference.isEmpty { Text("Reference: \(refund.reference)").foregroundStyle(Color.swMuted).textSelection(.enabled) } + } + } + } + NavigationLink("Receipts and credit notes") { PaymentDocumentsView(state: state, paymentID: paymentID) } + .frame(minHeight: 48) + } else { + Text("Payment unavailable").font(.title.bold()) + Text("Refresh your payments to see the latest information.").foregroundStyle(Color.swMuted) + Button("Refresh payments") { Task { await state.refresh() } }.frame(minHeight: 48).disabled(!state.canRequest) + } + }.padding(24).frame(maxWidth: 680).frame(maxWidth: .infinity) + }.background(Color.swBg).foregroundStyle(Color.swInk).tint(Color.swAccentInk) + .navigationTitle("Payment details").navigationBarTitleDisplayMode(.inline) + .background(PaymentPresentationAnchor(context: presentation).frame(width: 0, height: 0)) + .task { if state.load == .idle { await state.refresh() } } + #if DEBUG + .confirmationDialog("Demo checkout · no money moves", isPresented: $demoChoice, titleVisibility: .visible) { + Button("Preview confirmed payment") { Task { await state.simulateDemo(paymentID: paymentID, scenario: .paid) } } + Button("Preview waiting for confirmation") { Task { await state.simulateDemo(paymentID: paymentID, scenario: .pending) } } + Button("Preview failed checkout") { Task { await state.simulateDemo(paymentID: paymentID, scenario: .failed) } } + Button("Preview canceled checkout") { Task { await state.simulateDemo(paymentID: paymentID, scenario: .cancelled) } } + Button("Cancel", role: .cancel) {} + } message: { Text("These sample results do not open a payment SDK or contact a server.") } + #endif + } + @ViewBuilder private var checkoutStatus: some View { + switch state.checkout { + case .preparing(let id) where id == paymentID: ProgressView("Preparing checkout…") + case .presenting(let id) where id == paymentID: ProgressView("Checkout is open…") + case .checking(let id) where id == paymentID: ProgressView("Checking payment…") + case .awaiting(let id) where id == paymentID: + if let message = state.reconciliationError { SWError(message: message) } + Label("Still waiting for confirmation. You can safely leave and check again later.", systemImage: "clock") + .foregroundStyle(Color.swMuted).accessibilityIdentifier("payment-awaiting") + #if DEBUG + if state.isDemo { + Button("Preview delayed demo confirmation") { Task { await state.simulateDelayedConfirmation(paymentID: paymentID) } } + .frame(minHeight: 48).accessibilityIdentifier("payment-demo-delayed-confirmation") + } + #endif + case .completed(let id) where id == paymentID: + Label(state.isDemo ? "Demo payment confirmed" : "Payment status confirmed", systemImage: "checkmark.circle") + .foregroundStyle(Color.swSuccessInk).accessibilityIdentifier("payment-confirmed") + case .stopped(let id, let message) where id == paymentID: + SWError(message: message).accessibilityIdentifier("payment-checkout-error") + if state.requiresStatusRefresh(paymentID) { + Button("Refresh payment status") { Task { await state.refresh() } }.frame(minHeight: 48).disabled(!state.canRequest) + .accessibilityIdentifier("payment-refresh-status") + } + default: EmptyView() + } + } + private func startCheckout() { + #if DEBUG + if state.isDemo { demoChoice = true; return } + #endif + Task { + guard let (attempt, intent) = await state.prepareCheckout(paymentID: paymentID) else { return } + state.registerPresentationReset { [presentation] in presentation.payments.reset() } + presentation.present(intent: intent, isDemo: state.isDemo) { outcome in + Task { await state.finishSDK(outcome, attempt: attempt, paymentID: paymentID) } + } + } + } +} + +struct PaymentDocumentsView: View { + @Bindable var state: PaymentsState + var paymentID: Int? + @State private var share = false + private var documents: [Components.Schemas.PaymentDocument] { + state.documents.filter { paymentID == nil || $0.payment == paymentID } + } + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 24) { + Text("Receipts and credit notes").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text("Open or share documents issued by your makerspace.").foregroundStyle(Color.swMuted) + if state.isDemo { Text("Demo documents · sample data").foregroundStyle(Color.swMuted) } + if case .loading = state.documentsLoad { ProgressView("Loading documents…") } + if case .failed(let message) = state.documentsLoad { + SWError(message: message) + Button("Try again") { Task { await state.loadDocuments() } }.frame(minHeight: 48).disabled(!state.canRequest) + } + if state.documentsLoad == .ready && documents.isEmpty { + Text(paymentID != nil && state.documentsHaveMore ? "No matching documents on the loaded pages. Load more to continue." : "No issued documents yet.").foregroundStyle(Color.swMuted) + } + if let error = state.documentError { SWError(message: error) } + if state.documentBusy { ProgressView("Preparing document…") } + ForEach(documents, id: \.id) { document in + SWCard { + Text(document.kind.rawValue == "credit_note" ? "Credit note \(document.number)" : "Receipt \(document.number)").font(.headline) + PaymentMoney(amount: document.amount, currency: document.currency) + Text(document.issued_at, format: .dateTime.day().month().year()).foregroundStyle(Color.swMuted) + Button("Open PDF") { Task { await state.openDocument(document) } }.frame(minHeight: 48) + .disabled(state.documentBusy || !state.canRequest).accessibilityIdentifier("document-\(document.id)") + } + } + if state.documentsHaveMore { + Button("Load more documents") { Task { await state.loadDocuments(more: true) } }.frame(minHeight: 48) + .disabled(state.documentsLoad == .loading || !state.canRequest) + } + }.padding(24).frame(maxWidth: 800).frame(maxWidth: .infinity) + }.background(Color.swBg).foregroundStyle(Color.swInk).tint(Color.swAccentInk) + .navigationTitle("Documents").navigationBarTitleDisplayMode(.inline) + .task { if state.documentsLoad == .idle { await state.loadDocuments() } } + .refreshable { await state.loadDocuments() } + .onChange(of: state.preview?.id) { _, _ in share = false } + .sheet(item: $state.preview) { file in + NavigationStack { + NativeDocumentPreview(file: file).navigationTitle("Payment document").navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Done") { state.preview = nil } } + ToolbarItem(placement: .primaryAction) { Button("Share", systemImage: "square.and.arrow.up") { share = true } } + } + .sheet(isPresented: $share) { NativeDocumentShare(file: file) } + } + } + } +} + +private struct PaymentMoney: View { + let amount: String + let currency: String + var body: some View { Text("\(currency.uppercased()) \(amount)").monospacedDigit().foregroundStyle(Color.swInk) } +} +enum PaymentCopy { + static func status(_ raw: String?) -> String { + switch raw { + case "pending": return String(localized: "Payment pending") + case "paid_online": return String(localized: "Paid online") + case "paid_offline": return String(localized: "Paid at the desk") + case "waived": return String(localized: "Charge waived") + case "canceled": return String(localized: "Canceled") + default: return String(localized: "Status unavailable") + } + } +} + +@MainActor @Observable private final class PaymentPresentationContext { + @ObservationIgnored weak var anchor: UIViewController? + let payments = NativePayments() + func present(intent: Components.Schemas.MobilePaymentIntentResponse, isDemo: Bool, + completion: @escaping (NativePaymentOutcome) -> Void) { + guard var controller = anchor, controller.viewIfLoaded?.window != nil else { + completion(.unavailable("Checkout is not ready. Try again from payment details.")); return + } + while let parent = controller.parent { controller = parent } + // Current contract is Stripe-specific. No vendor inference or Razorpay field synthesis. + payments.presentStripe(clientSecret: intent.client_secret, publishableKey: intent.publishable_key, + customerID: intent.customer_id.flatMap { $0 }, ephemeralKey: intent.ephemeral_key.flatMap { $0 }, + returnURL: URL(string: "spaceworks://payments/return"), isDemo: isDemo, from: controller, completion: completion) + } +} +private struct PaymentPresentationAnchor: UIViewControllerRepresentable { + let context: PaymentPresentationContext + func makeUIViewController(context: Context) -> UIViewController { + let controller = UIViewController(); self.context.anchor = controller; return controller + } + func updateUIViewController(_ controller: UIViewController, context: Context) { self.context.anchor = controller } +} diff --git a/SpaceWorks/RootView.swift b/SpaceWorks/RootView.swift index d6aec92..f944dbc 100644 --- a/SpaceWorks/RootView.swift +++ b/SpaceWorks/RootView.swift @@ -168,20 +168,22 @@ private struct MemberWorkspace: View { @State private var cart: RequestCartState @State private var drafts: MemberActionDrafts @State private var capabilities: MemberCapabilities + @State private var payments: PaymentsState let actions: LiveMemberActionsRepository @State private var columns = NavigationSplitViewVisibility.automatic init(state: AppState, auth: AuthState, origin: ServerOrigin, id: Int, slug: String, linkRouter: MobileLinkRouter) { self.state = state; self.auth = auth; self.linkRouter = linkRouter; self.makerspaceID = id - let actions = LiveMemberActionsRepository(service: auth.service, id: id, slug: slug, - onUnauthorized: { [weak auth] message in await auth?.invalidateSession(message: message) }, - onForbidden: { [weak auth] in await auth?.foreground() }) + let failureHandlers = auth.repositoryFailureHandlers() + let actions = LiveMemberActionsRepository(service: auth.service, expectedCredentials: auth.acceptedCredentials, id: id, slug: slug, + onUnauthorized: failureHandlers.unauthorized, onForbidden: failureHandlers.forbidden) self.actions = actions + _payments = State(initialValue: PaymentsState(repository: LivePaymentRepository(service: auth.service, expectedCredentials: auth.acceptedCredentials, makerspaceID: id, origin: origin, + onUnauthorized: failureHandlers.unauthorized, onForbidden: failureHandlers.forbidden))) _cart = State(initialValue: RequestCartState(repository: actions)) _drafts = State(initialValue: MemberActionDrafts(repository: actions)) _capabilities = State(initialValue: MemberCapabilities(repository: actions, makerspaceID: id)) - _member = State(initialValue: MemberState(repository: LiveMemberRepository(service: auth.service, origin: origin, id: id, slug: slug, - onUnauthorized: { [weak auth] message in await auth?.invalidateSession(message: message) }, - onForbidden: { [weak auth] in await auth?.foreground() }), + _member = State(initialValue: MemberState(repository: LiveMemberRepository(service: auth.service, expectedCredentials: auth.acceptedCredentials, origin: origin, id: id, slug: slug, + onUnauthorized: failureHandlers.unauthorized, onForbidden: failureHandlers.forbidden), settings: .standard, context: "\(origin.url.absoluteString).\(auth.user?.id ?? 0).\(id)")) } var body: some View { @@ -217,20 +219,22 @@ private struct MemberWorkspace: View { columns = width >= 1000 ? .all : .detailOnly } }.task { - auth.registerPrivateHolder(privateHolderOwner) { [member, cart, drafts, capabilities, navigation] in - member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate() + auth.registerPrivateHolder(privateHolderOwner) { [member, cart, drafts, capabilities, navigation, payments] in + member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate() } async let config: Void = capabilities.load(); await member.loadUnreadCount(); await config }.onDisappear { auth.unregisterPrivateHolder(privateHolderOwner) - member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate() + member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate() } .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await capabilities.load(); await member.loadHome(); await member.loadUnreadCount() } } } .task(id: linkRouter.approved?.id) { if let approved = linkRouter.approved, approved.link.makerspaceID == makerspaceID { - await MobileLinkDestination.open(approved, router: linkRouter, member: member, navigation: navigation, drafts: drafts) + await MobileLinkDestination.open(approved, router: linkRouter, member: member, navigation: navigation, drafts: drafts, payment: { id in + try await PaymentsLinkDestination.open(id, state: payments, navigation: navigation) { linkRouter.isCurrent(approved) } + }) } } .onChange(of: auth.inboxRefreshRevision) { _, _ in @@ -241,9 +245,9 @@ private struct MemberWorkspace: View { } private func destination(_ tab: AppTab) -> some View { NavigationStack(path: navigation.path(tab)) { - MemberScreen(state: state, auth: auth, member: member, tab: tab, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, linkRouter: linkRouter) + MemberScreen(state: state, auth: auth, member: member, tab: tab, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, payments: payments, linkRouter: linkRouter) .navigationDestination(for: MemberRoute.self) { route in - MemberRouteView(route: route, member: member, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, isDemo: false) { await auth.foreground() } + MemberRouteView(route: route, member: member, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, isDemo: false, payments: payments) { await auth.foreground() } } } } @@ -260,13 +264,14 @@ private struct MemberScreen: View { let drafts: MemberActionDrafts let navigation: MemberNavigation let capabilities: MemberCapabilities + let payments: PaymentsState let linkRouter: MobileLinkRouter @State private var accountForm: AccountAccessState? @State private var replayIntro = false @State private var confirmSignOut = false var body: some View { Group { - if tab != .profile { MemberContent(state: member, tab: tab, name: auth.displayName, selectTab: { navigation.selected = $0 }, actions: actions, cart: cart, navigation: navigation, drafts: drafts, openLink: { text in + if tab != .profile { MemberContent(state: member, tab: tab, name: auth.displayName, selectTab: { navigation.selected = $0 }, actions: actions, cart: cart, navigation: navigation, drafts: drafts, paymentsAvailable: true, openLink: { text in guard let origin = state.connectedOrigin else { linkRouter.refuseURL(); return } if let link = MobileLink.inbox(text, origin: origin) { linkRouter.receive(link) } else if let legacy = MobileLink.legacy(text, origin: origin) { linkRouter.receive(legacy) } @@ -285,6 +290,7 @@ private struct MemberScreen: View { if let error = capabilities.error { SWError(message: error); Button("Retry account options") { Task { await capabilities.load() } }.frame(minHeight: 48) } if capabilities.selfService { NavigationLink("Edit profile", value: MemberRoute.contact).frame(minHeight: 48) } if capabilities.preferences { NavigationLink("Communication preferences", value: MemberRoute.preferences).frame(minHeight: 48) } + NavigationLink("Payments", value: MemberRoute.payments).frame(minHeight: 48).accessibilityIdentifier("payments-entry") if let notifications = auth.notifications { NavigationLink("Notifications") { NotificationSettingsView(state: notifications) }.frame(minHeight: 48).accessibilityIdentifier("notifications-entry") } @@ -298,7 +304,7 @@ private struct MemberScreen: View { .sheet(item: $accountForm) { form in AccountAccessView(form: form) { accountForm = nil } } .sheet(isPresented: $replayIntro) { IntroductionView { replayIntro = false } } .confirmationDialog("Sign out?", isPresented: $confirmSignOut, titleVisibility: .visible) { - Button("Sign out", role: .destructive) { member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); Task { await auth.signOut() } } + Button("Sign out", role: .destructive) { member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); Task { await auth.signOut() } } Button("Cancel", role: .cancel) {} } message: { Text("You'll need to sign in again to use this account.") } .toolbar { @@ -308,7 +314,7 @@ private struct MemberScreen: View { Button(membership.slug) { guard auth.selectedID != membership.id else { return } linkRouter.cancelIntent() - member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate() + member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate() Task { await auth.select(membership.id) } } } diff --git a/SpaceWorksTests/AuthTests.swift b/SpaceWorksTests/AuthTests.swift index dff02ba..247e180 100644 --- a/SpaceWorksTests/AuthTests.swift +++ b/SpaceWorksTests/AuthTests.swift @@ -57,14 +57,35 @@ final class MemoryTokens: TokenStorage, @unchecked Sendable { await auth.restore(); XCTAssertEqual(auth.stage, .signIn) auth.username = " maker "; auth.password = "password-fixture" await auth.signIn() - XCTAssertEqual(auth.stage, .signedIn) + XCTAssertEqual(auth.stage, .signedIn); XCTAssertNotNil(auth.acceptedCredentials) XCTAssertEqual(try storage.load(), TokenPair(access: "test-access", refresh: "test-refresh")) XCTAssertEqual(auth.memberships.count, 2); XCTAssertNil(auth.selectedID) await auth.select(7); XCTAssertEqual(auth.selectedID, 7) XCTAssertEqual(auth.password, ""); XCTAssertFalse(auth.staffToolsAvailable) - await auth.signOut(); XCTAssertNil(try storage.load()); XCTAssertNil(auth.user) + await auth.signOut(); XCTAssertNil(try storage.load()); XCTAssertNil(auth.user); XCTAssertNil(auth.acceptedCredentials) XCTAssertEqual(auth.stage, .signIn) } + func testFailureCallbackChecksAcceptedIdentityAfterMainActorHop() async throws { + let probe = OwnedRepositoryProbe() + let auth = state { request in + if request.url?.path == "/api/v1/auth/me" { probe.requested() } + return try self.response(request) + } + await auth.restore(); auth.username = "maker"; auth.password = "password-fixture"; await auth.signIn() + let retired = auth.repositoryFailureHandlers() + await auth.signOut() + auth.username = "maker"; auth.password = "password-fixture"; await auth.signIn() + XCTAssertEqual(auth.stage, .signedIn) + let before = probe.counts.requests + await retired.unauthorized("Retired callback must not sign out the replacement.") + await retired.forbidden() + XCTAssertEqual(auth.stage, .signedIn); XCTAssertNotNil(auth.user); XCTAssertNotNil(auth.acceptedCredentials) + XCTAssertNil(auth.error); XCTAssertEqual(probe.counts.requests, before, "Retired forbidden callback must not refetch the replacement identity") + let current = auth.repositoryFailureHandlers() + await current.unauthorized("Current session ended.") + XCTAssertEqual(auth.stage, .signIn); XCTAssertNil(auth.user); XCTAssertNil(auth.acceptedCredentials) + XCTAssertEqual(auth.error, "Current session ended.") + } func testMFARejectsAccessOnlyBrowserResult() async { let auth = state { request in if request.url!.path.hasSuffix("/login") { return (200, DebugAuthFixtures.mfa) } diff --git a/SpaceWorksTests/MemberNetworkingTests.swift b/SpaceWorksTests/MemberNetworkingTests.swift index 65a536a..d0e1dec 100644 --- a/SpaceWorksTests/MemberNetworkingTests.swift +++ b/SpaceWorksTests/MemberNetworkingTests.swift @@ -85,7 +85,7 @@ final class MemberNetworkingTests: XCTestCase { DebugMemberFixtures.inventory.replacingOccurrences(of: "\"next\": null", with: "\"next\": \"https://other.example/api/v1/inventory/?page=2\"") } try await client.makerspace.select(7, activeMemberships: [7]) - let repository = LiveMemberRepository(service: client, origin: try ServerOrigin("https://spaceworks.example"), id: 7, slug: "makerspace-one") + let repository = LiveMemberRepository(service: client, expectedCredentials: await client.refresh.snapshot(), origin: try ServerOrigin("https://spaceworks.example"), id: 7, slug: "makerspace-one") do { _ = try await repository.inventory(page: 1, search: "", category: nil); XCTFail("Cross-origin next URL was accepted") } catch { XCTAssertEqual(APIError.code(error), "invalid_next_page") } } diff --git a/SpaceWorksTests/PaymentsTests.swift b/SpaceWorksTests/PaymentsTests.swift new file mode 100644 index 0000000..6720447 --- /dev/null +++ b/SpaceWorksTests/PaymentsTests.swift @@ -0,0 +1,401 @@ +import Foundation +import SpaceWorksAPI +import XCTest +@testable import SpaceWorks + +private enum PaymentFixture { + static func decode(_ value: String) throws -> T { + let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(T.self, from: Data(value.utf8)) + } + static func row(status: String = "pending", online: Bool = true) throws -> Components.Schemas.MemberPayment { + try decode(rowJSON(status: status, online: online)) + } + static func rowJSON(status: String = "pending", online: Bool = true) -> String { + """ + {"id":501,"subject_type":"loan_deposit","subject_label":"Equipment deposit","status":"\(status)","amount":"250.00", + "currency":"inr","due_at":null,"due_date":"2026-10-20","due_state":"upcoming","checkout_url":"", + "online_payment_available":\(online),"settlement":null,"refunded_amount":0,"refunds":[],"created_at":"2026-10-16T09:00:00Z"} + """ + } + static let documents = #"{"count":0,"next":null,"previous":null,"results":[]}"# + static func intent(id: Int = 501) throws -> Components.Schemas.MobilePaymentIntentResponse { + try decode("{\"payment_id\":\(id),\"client_secret\":\"pi_fixture_secret_test\",\"publishable_key\":\"pk_test_fixture\"}") + } +} + +private actor PaymentTestRepository: PaymentRepository { + var rows: [Components.Schemas.MemberPayment] + var intentID = 501 + var failure: APIError? + private var offline = false + private(set) var listCalls = 0 + private(set) var intentCalls = 0 + private(set) var documentCalls = 0 + private(set) var requestedPages: [Int] = [] + private var pages: [Int: Components.Schemas.DocumentPage] = [:] + init(status: String = "pending", online: Bool = true) throws { rows = [try PaymentFixture.row(status: status, online: online)] } + func configure(status: String) throws { rows = [try PaymentFixture.row(status: status)] } + func configure(intentID: Int) { self.intentID = intentID } + func configure(failure: APIError) { self.failure = failure } + func configureOffline() { offline = true } + func clearFailure() { failure = nil; offline = false } + func configureDocuments(page: Int, json: String) throws { + let value: Components.Schemas.DocumentPage = try PaymentFixture.decode(json) + pages[page] = value + } + func payments() async throws -> [Components.Schemas.MemberPayment] { + listCalls += 1 + if offline { throw URLError(.notConnectedToInternet) } + if let failure { throw failure } + return rows + } + func intent(paymentID: Int) async throws -> Components.Schemas.MobilePaymentIntentResponse { + intentCalls += 1; return try PaymentFixture.intent(id: intentID) + } + func documents(page: Int) async throws -> Components.Schemas.DocumentPage { + documentCalls += 1; requestedPages.append(page) + if let failure { throw failure } + return try pages[page] ?? PaymentFixture.decode(PaymentFixture.documents) + } + func documentPDF(documentID: Int) async throws -> Data { Data("%PDF-fixture".utf8) } +} + +@MainActor final class PaymentsTests: XCTestCase { + func testPaymentLinkRefetchesAndOpensOnlyAnActualConfirmedRecord() async throws { + let repository = try PaymentTestRepository(); let state = PaymentsState(repository: repository) + let navigation = MemberNavigation() + try await PaymentsLinkDestination.open(501, state: state, navigation: navigation) { true } + XCTAssertEqual(navigation.selected, .profile); XCTAssertEqual(navigation.paths[.profile], [.payment(501)]) + let calls = await repository.listCalls; XCTAssertEqual(calls, 1) + } + func testMissingFailedAndReplacedPaymentLinkCannotNavigateUsingCachedRows() async throws { + let repository = try PaymentTestRepository(); let state = PaymentsState(repository: repository) + let navigation = MemberNavigation() + await state.refresh() + do { try await PaymentsLinkDestination.open(999, state: state, navigation: navigation) { true }; XCTFail("Unknown charge opened") } + catch { XCTAssertEqual(APIError.status(error), 404) } + await repository.configure(failure: APIError(status: 503, code: "offline", detail: "Try later.")) + do { try await PaymentsLinkDestination.open(501, state: state, navigation: navigation) { true }; XCTFail("Cached charge opened after refusal") } + catch { XCTAssertEqual(APIError.display(error), "Try later.") } + var checks = 0 + do { try await PaymentsLinkDestination.open(501, state: state, navigation: navigation) { checks += 1; return checks < 2 }; XCTFail("Replaced link opened") } + catch { XCTAssertTrue(error is CancellationError) } + XCTAssertEqual(navigation.selected, .home); XCTAssertTrue(navigation.paths.isEmpty) + } + func testConcurrentForbiddenReadClearsCheckoutBeforeSDKResetAndRejectsItsLateCallback() async throws { + let repository = try PaymentTestRepository(); let state = PaymentsState(repository: repository) + await state.refresh(); let prepared = await state.prepareCheckout(paymentID: 501) + let (attempt, _) = try XCTUnwrap(prepared) + var resets = 0; state.registerPresentationReset { resets += 1 } + await repository.configure(failure: APIError(status: 403, code: "forbidden", detail: "Payments are unavailable.")) + await state.loadDocuments() + XCTAssertEqual(resets, 1); XCTAssertEqual(state.checkout, .idle) + XCTAssertTrue(state.records.isEmpty); XCTAssertTrue(state.documents.isEmpty); XCTAssertNil(state.preview) + XCTAssertEqual(state.load, .failed("Payments are unavailable.")) + let before = await repository.listCalls + await state.finishSDK(.awaitingServerConfirmation, attempt: attempt, paymentID: 501) + let after = await repository.listCalls; XCTAssertEqual(before, after) + } + func testSubmittedPaymentWithOfflineStatusReadCannotStartAnotherIntentAndFreshPaidReadCompletes() async throws { + let repository = try PaymentTestRepository(); let state = PaymentsState(repository: repository, sleep: { _ in }) + await state.refresh(); let prepared = await state.prepareCheckout(paymentID: 501) + let (attempt, _) = try XCTUnwrap(prepared) + await repository.configureOffline() + await state.finishSDK(.awaitingServerConfirmation, attempt: attempt, paymentID: 501) + XCTAssertEqual(state.checkout, .awaiting(501)); XCTAssertNotNil(state.reconciliationError) + XCTAssertFalse(state.canPay(try XCTUnwrap(state.record(501)))) + let next = await state.prepareCheckout(paymentID: 501); XCTAssertNil(next) + let intents = await repository.intentCalls; XCTAssertEqual(intents, 1) + await repository.clearFailure(); try await repository.configure(status: "paid_online") + await state.refresh(); XCTAssertEqual(state.checkout, .completed(501)); XCTAssertNil(state.reconciliationError) + } + func testCancelledOrFailedSDKWithOfflineReadRequiresFreshServerStatusBeforeAnotherCheckout() async throws { + for outcome in [NativePaymentOutcome.cancelled, .failed("Checkout failed.")] { + let repository = try PaymentTestRepository(); let state = PaymentsState(repository: repository) + await state.refresh(); let prepared = await state.prepareCheckout(paymentID: 501) + let (attempt, _) = try XCTUnwrap(prepared) + await repository.configureOffline(); await state.finishSDK(outcome, attempt: attempt, paymentID: 501) + XCTAssertTrue(state.requiresStatusRefresh(501)); XCTAssertFalse(state.canPay(try XCTUnwrap(state.record(501)))) + let retry = await state.prepareCheckout(paymentID: 501); XCTAssertNil(retry) + let intents = await repository.intentCalls; XCTAssertEqual(intents, 1) + await repository.clearFailure(); await state.refresh() + XCTAssertFalse(state.requiresStatusRefresh(501)); XCTAssertTrue(state.canPay(try XCTUnwrap(state.record(501)))) + XCTAssertEqual(state.record(501)?.status?.rawValue, "pending") + } + } + func testConcurrentDocumentThrottleStopsNextSubmittedPaymentPollUntilDeadline() async throws { + let repository = try PaymentTestRepository(); let gate = PaymentPollGate() + let state = PaymentsState(repository: repository, sleep: { _ in await gate.arrive() }) + await state.refresh(); let prepared = await state.prepareCheckout(paymentID: 501) + let (attempt, _) = try XCTUnwrap(prepared) + let checking = Task { await state.finishSDK(.awaitingServerConfirmation, attempt: attempt, paymentID: 501) } + await gate.entered() + let before = await repository.listCalls + await repository.configure(failure: APIError(status: 429, code: "throttled", detail: "Wait before trying again.", retryAfter: Date().addingTimeInterval(60))) + await state.loadDocuments(); XCTAssertFalse(state.canRequest) + await gate.release(); await checking.value + let after = await repository.listCalls + XCTAssertEqual(before, after); XCTAssertEqual(state.checkout, .awaiting(501)) + XCTAssertFalse(state.canPay(try XCTUnwrap(state.record(501)))) + XCTAssertEqual(state.reconciliationError, "Wait before trying again."); state.invalidate() + } + func testSDKSuccessNeverMarksPendingPaymentPaid() async throws { + let repository = try PaymentTestRepository() + let state = PaymentsState(repository: repository, sleep: { _ in }) + await state.refresh() + let prepared = await state.prepareCheckout(paymentID: 501) + let (attempt, _) = try XCTUnwrap(prepared) + await state.finishSDK(.awaitingServerConfirmation, attempt: attempt, paymentID: 501) + XCTAssertEqual(state.checkout, .awaiting(501)) + XCTAssertEqual(state.record(501)?.status?.rawValue, "pending") + let polls = await repository.listCalls + XCTAssertEqual(polls, 6) // Initial load, fresh checkout authorization, four bounded reconciliation reads. + XCTAssertFalse(state.canPay(try XCTUnwrap(state.record(501)))) + } + func testServerPaidStatusWinsEvenWhenSDKReportsCancellation() async throws { + let repository = try PaymentTestRepository() + let state = PaymentsState(repository: repository, sleep: { _ in }) + await state.refresh() + let prepared = await state.prepareCheckout(paymentID: 501) + let (attempt, _) = try XCTUnwrap(prepared) + try await repository.configure(status: "paid_online") + await state.finishSDK(.cancelled, attempt: attempt, paymentID: 501) + XCTAssertEqual(state.checkout, .completed(501)) + XCTAssertEqual(state.record(501)?.status?.rawValue, "paid_online") + let documentCalls = await repository.documentCalls + XCTAssertEqual(documentCalls, 1) + } + func testPullToRefreshReplacesAwaitingNoticeAfterAuthoritativePaymentConfirmation() async throws { + let repository = try PaymentTestRepository() + let state = PaymentsState(repository: repository, sleep: { _ in }) + await state.refresh() + let prepared = await state.prepareCheckout(paymentID: 501) + let (attempt, _) = try XCTUnwrap(prepared) + await state.finishSDK(.awaitingServerConfirmation, attempt: attempt, paymentID: 501) + XCTAssertEqual(state.checkout, .awaiting(501)) + try await repository.configure(status: "paid_online") + await state.refresh() + XCTAssertEqual(state.checkout, .completed(501)) + let documents = await repository.documentCalls + XCTAssertEqual(documents, 1) + } + func testDocumentsFollowActualNextPageAndMergeDuplicateIDsOnlyOnce() async throws { + let repository = try PaymentTestRepository() + let first = #"{"id":901,"payment":501,"number":"R1","kind":"receipt","issued_at":"2026-10-16T09:00:00Z","money_at":"2026-10-16T09:00:00Z","amount":"250.00","currency":"inr"}"# + let second = first.replacingOccurrences(of: "901", with: "902") + try await repository.configureDocuments(page: 1, json: "{\"count\":2,\"next\":\"https://spaceworks.example/api/v1/member/makerspaces/7/payment-documents?page=3\",\"previous\":null,\"results\":[\(first)]}") + try await repository.configureDocuments(page: 3, json: "{\"count\":2,\"next\":null,\"previous\":null,\"results\":[\(first),\(second)]}") + let state = PaymentsState(repository: repository) + await state.loadDocuments() + await state.loadDocuments(more: true) + let pages = await repository.requestedPages + XCTAssertEqual(pages, [1, 3]) + XCTAssertEqual(state.documents.map(\.id), [901, 902]) + XCTAssertFalse(state.documentsHaveMore) + } + func testNewServerStatusPreventsLaunchingAnOutdatedCharge() async throws { + let repository = try PaymentTestRepository() + let state = PaymentsState(repository: repository, sleep: { _ in }) + await state.refresh() + try await repository.configure(status: "waived") + let result = await state.prepareCheckout(paymentID: 501) + XCTAssertNil(result) + XCTAssertEqual(state.record(501)?.status?.rawValue, "waived") + let intents = await repository.intentCalls + XCTAssertEqual(intents, 0) + } + func testOfflineOnlyChargeAndMismatchedIntentNeverReachSDK() async throws { + let offline = try PaymentTestRepository(online: false) + let offlineState = PaymentsState(repository: offline) + await offlineState.refresh() + let unavailable = await offlineState.prepareCheckout(paymentID: 501) + XCTAssertNil(unavailable) + let offlineIntents = await offline.intentCalls + XCTAssertEqual(offlineIntents, 0) + let repository = try PaymentTestRepository() + await repository.configure(intentID: 999) + let state = PaymentsState(repository: repository) + await state.refresh() + let wrongPayment = await state.prepareCheckout(paymentID: 501) + XCTAssertNil(wrongPayment) + XCTAssertFalse(state.busy) + } + func testLogoutClearsPaymentDataResetsSDKAndRejectsLateCallback() async throws { + let repository = try PaymentTestRepository() + var reset = false + let state = PaymentsState(repository: repository, sleep: { _ in }) + state.registerPresentationReset { reset = true } + await state.refresh() + let prepared = await state.prepareCheckout(paymentID: 501) + let (attempt, _) = try XCTUnwrap(prepared) + state.invalidate() + await state.finishSDK(.awaitingServerConfirmation, attempt: attempt, paymentID: 501) + XCTAssertTrue(reset) + XCTAssertTrue(state.records.isEmpty) + XCTAssertTrue(state.documents.isEmpty) + XCTAssertEqual(state.checkout, .idle) + } + func testUnauthorizedReadClearsScopeAndReturnsToAuthentication() async throws { + let repository = try PaymentTestRepository() + var signedOut = 0 + let state = PaymentsState(repository: repository, onUnauthorized: { signedOut += 1 }) + await state.refresh() + await repository.configure(failure: APIError(status: 401, code: "session_expired", detail: "Expired")) + await state.refresh() + XCTAssertEqual(signedOut, 1) + XCTAssertTrue(state.records.isEmpty) + } + func testRetryAfterPreventsRepeatedCheckoutAndStatusRequestsUntilCooldown() async throws { + let repository = try PaymentTestRepository() + let state = PaymentsState(repository: repository) + await state.refresh() + await repository.configure(failure: APIError(status: 429, code: "throttled", detail: "Please wait.", retryAfter: Date().addingTimeInterval(60))) + let result = await state.prepareCheckout(paymentID: 501) + XCTAssertNil(result) + XCTAssertFalse(state.canRequest) + let requestsBefore = await repository.listCalls + await state.refresh() + await state.checkStatus(paymentID: 501) + await state.loadDocuments() + let requestsAfter = await repository.listCalls + let documentsAfter = await repository.documentCalls + XCTAssertEqual(requestsBefore, requestsAfter) + XCTAssertEqual(documentsAfter, 0) + state.invalidate() + } + #if DEBUG + func testDemoPaymentRecordsRemainSeparateAcrossMakerspaceSwitchAndResetWithDemoSession() async throws { + let session = DemoSession(); let first = session.paymentRepository(for: 7); let second = session.paymentRepository(for: 8) + await first.simulate(paymentID: 501, scenario: .paid) + let firstRows = try await session.paymentRepository(for: 7).payments() + let secondRows = try await second.payments() + XCTAssertEqual(firstRows.first?.status?.rawValue, "paid_online"); XCTAssertEqual(secondRows.first?.status?.rawValue, "pending") + session.reset() + let reset = try await session.paymentRepository(for: 7).payments(); XCTAssertEqual(reset.first?.status?.rawValue, "pending") + } + func testDemoPaymentsUseIsolatedFixtureAndNeverRequestAnSDKIntent() async throws { + let state = PaymentsState(repository: DemoPaymentRepository(), isDemo: true, sleep: { _ in }) + await state.refresh() + let actualIntent = await state.prepareCheckout(paymentID: 501) + XCTAssertNil(actualIntent) + await state.simulateDemo(paymentID: 501, scenario: .paid) + XCTAssertEqual(state.checkout, .completed(501)) + XCTAssertEqual(state.record(501)?.status?.rawValue, "paid_online") + XCTAssertTrue(state.documents.contains { $0.payment == 501 }) + if let document = state.documents.first { + await state.openDocument(document) + XCTAssertNotNil(state.preview) + state.invalidate() + XCTAssertNil(state.preview) + } + } + #endif +} + +final class PaymentOperationTests: XCTestCase { + private let origin = try! ServerOrigin("https://spaceworks.example") + override func tearDown() { StubProtocol.lock.lock(); StubProtocol.handler = nil; StubProtocol.lock.unlock() } + private func service(_ handler: @escaping (URLRequest) throws -> (Int, [String: String], Data)) async throws -> SpaceWorksService { + StubProtocol.lock.lock(); StubProtocol.handler = handler; StubProtocol.lock.unlock() + let configuration = DeviceSession.configuration(); configuration.protocolClasses = [StubProtocol.self] + let service = SpaceWorksService(origin: origin, session: DeviceSession.make(configuration: configuration), + initialTokens: TokenPair(access: "fixture-access", refresh: "fixture-refresh")) + try await service.makerspace.select(7, activeMemberships: [7]) + return service + } + private func assertScoped(_ request: URLRequest) { + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer fixture-access") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Makerspace-Id"), "7") + for name in ["Cookie", "Origin", "Referer"] { XCTAssertNil(request.value(forHTTPHeaderField: name)) } + } + func testPaymentListIsGeneratedArrayAndIntentUsesExactDocumentedPOST() async throws { + let service = try await service { request in + self.assertScoped(request) + if request.url?.path == "/api/v1/member/makerspaces/7/payments" { + XCTAssertEqual(request.httpMethod, "GET") + return (200, ["Content-Type": "application/json"], Data("[\(PaymentFixture.rowJSON())]".utf8)) + } + XCTAssertEqual(request.url?.path, "/api/v1/member/makerspaces/7/payments/501/mobile-intent") + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertTrue(requestBody(request).isEmpty) + return (200, ["Content-Type": "application/json"], Data(#"{"payment_id":501,"client_secret":"pi_fixture_secret_test","publishable_key":"pk_test_fixture"}"#.utf8)) + } + let rows = try await service.memberPayments(makerspaceID: 7) + XCTAssertEqual(rows.first?.id, 501) + let intent = try await service.memberPaymentIntent(makerspaceID: 7, paymentID: 501) + XCTAssertEqual(intent.payment_id, 501) + } + func testDocumentListUsesGeneratedPagingAndPDFRetainsRequiredAPIAuthentication() async throws { + let service = try await service { request in + self.assertScoped(request) + let path = try XCTUnwrap(request.url?.path) + if path.hasSuffix("/payment-documents") { + XCTAssertEqual(URLComponents(url: request.url!, resolvingAgainstBaseURL: true)?.queryItems?.first?.value, "2") + return (200, ["Content-Type": "application/json"], Data(PaymentFixture.documents.utf8)) + } + if path.hasSuffix("/download-url") { + XCTAssertEqual(path, "/api/v1/member/makerspaces/7/payment-documents/901/download-url") + return (200, ["Content-Type": "application/json"], Data(#"{"url":"/api/v1/payment-documents/901/download?token=short-lived","expires_in":300}"#.utf8)) + } + XCTAssertEqual(path, "/api/v1/payment-documents/901/download") + XCTAssertEqual(URLComponents(url: request.url!, resolvingAgainstBaseURL: true)?.queryItems?.first?.value, "short-lived") + return (200, ["Content-Type": "application/pdf"], Data("%PDF-fixture".utf8)) + } + _ = try await service.memberPaymentDocuments(makerspaceID: 7, page: 2) + let bytes = try await service.memberPaymentPDF(makerspaceID: 7, documentID: 901, origin: origin) + XCTAssertTrue(bytes.starts(with: Data("%PDF-".utf8))) + } + func testDownloadGrantRejectsExternalWrongResourceAndAmbiguousQueryBeforeAnyAuthenticatedFetch() throws { + let token = try PaymentDownloadGrant.token(from: "/api/v1/payment-documents/901/download?token=valid", documentID: 901, origin: origin) + XCTAssertEqual(token, "valid") + XCTAssertEqual(try PaymentDownloadGrant.token(from: "https://spaceworks.example:443/api/v1/payment-documents/901/download?token=valid", documentID: 901, origin: origin), "valid") + XCTAssertEqual(try PaymentDownloadGrant.token(from: "https://spaceworks.example/api/v1/payment-documents/901/download?token=valid", documentID: 901, + origin: ServerOrigin("https://spaceworks.example:443")), "valid") + for value in ["https://evil.example/api/v1/payment-documents/901/download?token=x", + "http://spaceworks.example/api/v1/payment-documents/901/download?token=x", + "https://user:pass@spaceworks.example/api/v1/payment-documents/901/download?token=x", + "/api/v1/payment-documents/902/download?token=x", "/api/v1/payment-documents/901/download?token=x&token=y", + "/api/v1/payment-documents/901/download?token=x#fragment", "/api/v1/payment-documents/901/download?token=", + "/api/v1/payment-documents/901/%2E%2E/download?token=x"] { + XCTAssertThrowsError(try PaymentDownloadGrant.token(from: value, documentID: 901, origin: origin), value) + } + } + func testWrappedCancellationAfterLocalTokenWipeDoesNotTriggerAnotherAccountLogout() async throws { + let callbacks = PaymentRequestCounter() + let service = try await service { _ in throw URLError(.cancelled) } + let credentials = await service.refresh.snapshot() + try await service.refresh.install(nil) + let repository = LivePaymentRepository(service: service, expectedCredentials: credentials, makerspaceID: 7, origin: origin, onUnauthorized: { _ in callbacks.increment() }) + do { _ = try await repository.payments(); XCTFail("Expected cancelled transport") } + catch { XCTAssertTrue(PaymentOperationFailure.isCancellation(error)) } + XCTAssertEqual(callbacks.value, 0) + } + func testInvalidGrantNeverMakesSecondRequestToItsTarget() async throws { + let count = PaymentRequestCounter() + let service = try await service { request in + count.increment(); self.assertScoped(request) + XCTAssertTrue(request.url!.path.hasSuffix("/download-url")) + return (200, ["Content-Type": "application/json"], Data(#"{"url":"https://evil.example/download?token=secret","expires_in":300}"#.utf8)) + } + do { _ = try await service.memberPaymentPDF(makerspaceID: 7, documentID: 901, origin: origin); XCTFail("Invalid grant accepted") } + catch { XCTAssertEqual(APIError.code(error), "invalid_document_grant") } + XCTAssertEqual(count.value, 1) + } +} + +private final class PaymentRequestCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + var value: Int { lock.lock(); defer { lock.unlock() }; return count } + func increment() { lock.lock(); count += 1; lock.unlock() } +} + +private actor PaymentPollGate { + private var started = false + private var continuation: CheckedContinuation? + private var observer: CheckedContinuation? + func arrive() async { started = true; observer?.resume(); observer = nil; await withCheckedContinuation { continuation = $0 } } + func entered() async { if !started { await withCheckedContinuation { observer = $0 } } } + func release() { continuation?.resume(); continuation = nil } +} diff --git a/SpaceWorksTests/RepositoryOwnershipTests.swift b/SpaceWorksTests/RepositoryOwnershipTests.swift new file mode 100644 index 0000000..bba9abf --- /dev/null +++ b/SpaceWorksTests/RepositoryOwnershipTests.swift @@ -0,0 +1,101 @@ +import Foundation +import SpaceWorksAPI +import XCTest +@testable import SpaceWorks + +final class OwnedRepositoryProbe: @unchecked Sendable { + private let lock = NSLock() + private var requests = 0 + private var callbacks = 0 + var counts: (requests: Int, callbacks: Int) { lock.lock(); defer { lock.unlock() }; return (requests, callbacks) } + func requested() { lock.lock(); requests += 1; lock.unlock() } + func notified() { lock.lock(); callbacks += 1; lock.unlock() } +} + +struct OwnedRepositoryFixture { + let service: SpaceWorksService + let credentials: CredentialSnapshot + let probe: OwnedRepositoryProbe + let origin: ServerOrigin + static func make() async throws -> OwnedRepositoryFixture { + let probe = OwnedRepositoryProbe() + StubProtocol.lock.lock() + StubProtocol.handler = { _ in + probe.requested(); return (200, ["Content-Type": "application/json"], Data("{}".utf8)) + } + StubProtocol.lock.unlock() + let configuration = DeviceSession.configuration(); configuration.protocolClasses = [StubProtocol.self] + let origin = try ServerOrigin("https://spaceworks.example") + let service = SpaceWorksService(origin: origin, session: DeviceSession.make(configuration: configuration), + initialTokens: .init(access: "old-account-access", refresh: "old-account-refresh")) + try await service.makerspace.select(7, activeMemberships: [7]) + return OwnedRepositoryFixture(service: service, credentials: await service.refresh.snapshot(), probe: probe, origin: origin) + } + func replaceAccount() async throws { + try await service.refresh.install(nil) + try await service.refresh.install(.init(access: "replacement-account-access", refresh: "replacement-account-refresh")) + try await service.makerspace.select(7, activeMemberships: [7]) + } +} + +final class RepositoryOwnershipTests: XCTestCase { + override func tearDown() { StubProtocol.lock.lock(); StubProtocol.handler = nil; StubProtocol.lock.unlock() } + private func refused(_ work: @Sendable () async throws -> Void, file: StaticString = #filePath, line: UInt = #line) async { + do { try await work(); XCTFail("Retired repository used replacement-account authority", file: file, line: line) } + catch { XCTAssertTrue(APIError.isCancellation(error), "Expected an owner cancellation, received \(error)", file: file, line: line) } + } + func testAcceptedRepositoryOwnerSurvivesSameInstallationRefreshRotation() async throws { + let fixture = try await OwnedRepositoryFixture.make(); let probe = fixture.probe + StubProtocol.lock.lock() + StubProtocol.handler = { request in + probe.requested() + if request.url?.path == "/api/v1/auth/device/refresh" { + return (200, ["Content-Type": "application/json"], Data(DebugAuthFixtures.tokens.utf8)) + } + if request.value(forHTTPHeaderField: "Authorization") == "Bearer old-account-access" { + return (401, ["Content-Type": "application/json"], Data(#"{"code":"token_expired","detail":"Expired."}"#.utf8)) + } + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer test-access") + return (200, ["Content-Type": "application/json"], Data(DebugMemberFixtures.categories.utf8)) + } + StubProtocol.lock.unlock() + let repository = LiveMemberRepository(service: fixture.service, expectedCredentials: fixture.credentials, + origin: fixture.origin, id: 7, slug: "makerspace-one", onUnauthorized: { _ in probe.notified() }) + _ = try await repository.categories(); _ = try await repository.categories() + let same = await fixture.service.refresh.isSameInstallation(fixture.credentials) + XCTAssertTrue(same); XCTAssertEqual(probe.counts.requests, 4); XCTAssertEqual(probe.counts.callbacks, 0) + } + func testRetainedMemberRepositoryCannotFirstReadOrMarkInboxUnderReplacementAccount() async throws { + let fixture = try await OwnedRepositoryFixture.make(); let probe = fixture.probe + let repository = LiveMemberRepository(service: fixture.service, expectedCredentials: fixture.credentials, + origin: fixture.origin, id: 7, slug: "makerspace-one", onUnauthorized: { _ in probe.notified() }, onForbidden: { probe.notified() }) + try await fixture.replaceAccount() + await refused { _ = try await repository.inventory(page: 1, search: "", category: nil) } + await refused { _ = try await repository.card() } + await refused { try await repository.markAllRead() } + XCTAssertEqual(probe.counts.requests, 0); XCTAssertEqual(probe.counts.callbacks, 0) + let pair = await fixture.service.refresh.current(); XCTAssertEqual(pair?.access, "replacement-account-access") + } + func testRetainedMemberActionsCannotFirstMutateUsingReplacementAccount() async throws { + let fixture = try await OwnedRepositoryFixture.make(); let probe = fixture.probe + let repository = LiveMemberActionsRepository(service: fixture.service, expectedCredentials: fixture.credentials, + id: 7, slug: "makerspace-one", onUnauthorized: { _ in probe.notified() }, onForbidden: { probe.notified() }) + try await fixture.replaceAccount() + await refused { _ = try await repository.renew(request: 601) } + await refused { _ = try await repository.cancelBooking(token: "opaque-booking") } + await refused { _ = try await repository.updateProfile(.init(display_name: "Retired edit")) } + await refused { _ = try await repository.bootstrap() } + XCTAssertEqual(probe.counts.requests, 0); XCTAssertEqual(probe.counts.callbacks, 0) + } + func testRetainedPaymentsCannotReadCreateIntentOrDownloadUnderReplacementAccount() async throws { + let fixture = try await OwnedRepositoryFixture.make(); let probe = fixture.probe + let repository = LivePaymentRepository(service: fixture.service, expectedCredentials: fixture.credentials, + makerspaceID: 7, origin: fixture.origin, onUnauthorized: { _ in probe.notified() }, onForbidden: { probe.notified() }) + try await fixture.replaceAccount() + await refused { _ = try await repository.payments() } + await refused { _ = try await repository.intent(paymentID: 501) } + await refused { _ = try await repository.documents(page: 1) } + await refused { _ = try await repository.documentPDF(documentID: 901) } + XCTAssertEqual(probe.counts.requests, 0); XCTAssertEqual(probe.counts.callbacks, 0) + } +} diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index 6409516..960c126 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -30,8 +30,13 @@ final class MemberActionsFlowTests: XCTestCase { // native UISwitch activation point. Let XCTest activate a visible switch. if element.elementType == .switch, element.isHittable, !visible.isNull, visible.width > 4, visible.height > 4 { activate(element); return } - if element.isHittable, !visible.isNull, visible.width > 4, visible.height > 4, - viewport.insetBy(dx: 2, dy: 2).contains(CGPoint(x: frame.midX, y: frame.midY)) { + // A fitting review button must reveal its entire label before activation. + // Its midpoint can be hittable while the second line is below the screen. + let fullReviewButton = scroll.identifier == "request-review-scroll" && element.elementType == .button + && frame.height > 0 && frame.height <= viewport.height - 4 + let positioned = fullReviewButton ? viewport.insetBy(dx: 2, dy: 2).contains(frame) + : viewport.insetBy(dx: 2, dy: 2).contains(CGPoint(x: frame.midX, y: frame.midY)) + if element.isHittable, !visible.isNull, visible.width > 4, visible.height > 4, positioned { // Use the native activation point. A wide Toggle's label centre is not // its switch thumb; coordinates are only needed for clipped text fields. activate(element); return @@ -107,7 +112,19 @@ final class MemberActionsFlowTests: XCTestCase { // A fast released drag flung landscape content ~300pt across a 139pt // viewport. Hold at a slow, bounded endpoint so the next visible target is // examined before momentum can carry it under the navigation/tab bars. - let distance = min(200, viewport.height * 0.4) + var distance = min(200, viewport.height * 0.4) + if scroll.identifier == "request-review-scroll", target.exists { + let frame = target.frame + if frame.minY.isFinite, frame.maxY.isFinite, frame.height > 0, frame.height <= viewport.height - 4 { + let inset = viewport.insetBy(dx: 2, dy: 2) + let remaining = down ? inset.minY - frame.minY : frame.maxY - inset.maxY + if remaining > 0 { + // Native traces move 190pt for a 200pt drag (10pt pan slop). + // Approach the exposed edge precisely in short landscape content. + distance = min(distance, max(12, remaining + 10)) + } + } + } // The modal's multiline purpose field owns an inner scroll; its gutter // moves the parent. A generic tablet viewport may include the sidebar, so // keep other gestures within the target's horizontal content column. diff --git a/SpaceWorksUITests/NativeUIActions.swift b/SpaceWorksUITests/NativeUIActions.swift index 3b95e4a..c671216 100644 --- a/SpaceWorksUITests/NativeUIActions.swift +++ b/SpaceWorksUITests/NativeUIActions.swift @@ -17,8 +17,13 @@ enum NativeUIActions { // native UISwitch activation point. Let XCTest activate a visible switch. if element.elementType == .switch, element.isHittable, !visible.isNull, visible.width > 4, visible.height > 4 { element.tap(); return } - if element.isHittable, !visible.isNull, visible.width > 4, visible.height > 4, - viewport.insetBy(dx: 2, dy: 2).contains(CGPoint(x: frame.midX, y: frame.midY)) { + // A fitting review button must reveal its entire label before activation. + // Its midpoint can be hittable while the second line is below the screen. + let fullReviewButton = scroll.identifier == "request-review-scroll" && element.elementType == .button + && frame.height > 0 && frame.height <= viewport.height - 4 + let positioned = fullReviewButton ? viewport.insetBy(dx: 2, dy: 2).contains(frame) + : viewport.insetBy(dx: 2, dy: 2).contains(CGPoint(x: frame.midX, y: frame.midY)) + if element.isHittable, !visible.isNull, visible.width > 4, visible.height > 4, positioned { // Use the native activation point. A wide Toggle's label centre is not // its switch thumb; coordinates are only needed for clipped text fields. element.tap(); return @@ -92,7 +97,19 @@ enum NativeUIActions { // A fast released drag flung landscape content ~300pt across a 139pt // viewport. Hold at a slow, bounded endpoint so the next visible target is // examined before momentum can carry it under the navigation/tab bars. - let distance = min(200, viewport.height * 0.4) + var distance = min(200, viewport.height * 0.4) + if scroll.identifier == "request-review-scroll", target.exists { + let frame = target.frame + if frame.minY.isFinite, frame.maxY.isFinite, frame.height > 0, frame.height <= viewport.height - 4 { + let inset = viewport.insetBy(dx: 2, dy: 2) + let remaining = down ? inset.minY - frame.minY : frame.maxY - inset.maxY + if remaining > 0 { + // Native traces move 190pt for a 200pt drag (10pt pan slop). + // Approach the exposed edge precisely in short landscape content. + distance = min(distance, max(12, remaining + 10)) + } + } + } // The modal's multiline purpose field owns an inner scroll; its gutter // moves the parent. A generic tablet viewport may include the sidebar, so // keep other gestures within the target's horizontal content column. diff --git a/SpaceWorksUITests/PaymentsFlowTests.swift b/SpaceWorksUITests/PaymentsFlowTests.swift new file mode 100644 index 0000000..d6c8acb --- /dev/null +++ b/SpaceWorksUITests/PaymentsFlowTests.swift @@ -0,0 +1,47 @@ +import XCTest + +final class PaymentsFlowTests: XCTestCase { + override func setUp() { continueAfterFailure = false; XCUIDevice.shared.orientation = .portrait } + private func tap(_ element: XCUIElement, in app: XCUIApplication) { NativeUIActions.tap(element, in: app) } + private func member() -> XCUIApplication { + let app = XCUIApplication(); app.launchArguments = ["--ui-testing"]; app.launch() + tap(app.buttons["explore-demo"], in: app); tap(app.buttons["Continue as member"], in: app) + let username = app.textFields["demo-username"]; tap(username, in: app); username.typeText("member\n") + app.secureTextFields["demo-password"].typeText("sample\n") + tap(app.buttons["demo-sign-in"], in: app); tap(app.buttons["demo-space-0"], in: app) + if app.tabBars.buttons["Profile"].exists { tap(app.tabBars.buttons["Profile"], in: app) } + else { + let profile = app.descendants(matching: .any)["nav-Profile"].firstMatch + if !profile.isHittable { + let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'sidebar'")).firstMatch + if sidebar.exists { sidebar.tap() } + } + tap(profile, in: app) + } + tap(app.buttons["payments-entry"], in: app) + return app + } + private func attach(_ name: String) { + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); attachment.name = name; attachment.lifetime = .keepAlways; add(attachment) + } + func testDelayedPaymentRemainsPendingUntilDemoServerConfirmationThenOpensReceipt() { + let app = member(); tap(app.buttons["payment-501"], in: app) + tap(app.buttons["payment-continue"], in: app); tap(app.buttons["Preview waiting for confirmation"], in: app) + let awaiting = app.descendants(matching: .any)["payment-awaiting"].firstMatch + XCTAssertTrue(awaiting.waitForExistence(timeout: 10)); XCTAssertFalse(app.buttons["payment-continue"].exists) + attach("Payment awaiting authoritative confirmation") + tap(app.buttons["payment-demo-delayed-confirmation"], in: app) + XCTAssertTrue(app.descendants(matching: .any)["payment-confirmed"].firstMatch.waitForExistence(timeout: 10)) + tap(app.buttons["Receipts and credit notes"], in: app); tap(app.buttons["document-901"], in: app) + XCTAssertTrue(app.navigationBars["Payment document"].waitForExistence(timeout: 10)); attach("Payment native PDF receipt") + tap(app.buttons["Done"], in: app) + XCTAssertTrue(app.buttons["document-901"].waitForExistence(timeout: 5)) + } + func testCancelledDemoCheckoutKeepsChargePendingAndAllowsExplicitRetry() { + let app = member(); tap(app.buttons["payment-501"], in: app) + tap(app.buttons["payment-continue"], in: app); tap(app.buttons["Preview canceled checkout"], in: app) + XCTAssertTrue(app.buttons["payment-continue"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.descendants(matching: .any)["payment-confirmed"].firstMatch.exists) + attach("Payment cancelled with pending charge") + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 640398b..79ade4d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,7 +1,51 @@ # Phase notes +## A5 Payments — implementation checkpoint (2026-10-17) + +Rerun every existing A4 journey with a stronger request-review check: a fitting button must be +fully inside the visible content before tapping and before the screenshot. Cap review-only drag +travel to the remaining exposed edge, accounting for the pan activation slop observed in native +traces; the 24-attempt bound, native gesture and generic controls remain unchanged. The previous +A4 native/Release run passed, but its clipped before-submit phone capture was not visually accepted. +This combined gate must demonstrate the entire primary label before further phase acceptance. + +Add the generated payment array, native Stripe intent, document list, download grant and authenticated +PDF operations. Payments are available from Home, Activity and Profile. A checkout callback never +marks a payment paid: a fresh scoped server list is the authority. A failed post-sheet status read +keeps every SDK outcome nonpayable until an explicit successful refresh. Bounded reconciliation and +all document/intent actions honor retained Retry-After deadlines, including a concurrent receipt +request that becomes throttled. Account, server and makerspace changes cancel presentation and +invalidate cached records before late callbacks can act. + +Bind member reads/actions and payments to the accepted device-session installation before their +first executor hop. A retained repository cannot borrow a replacement account's credentials, and +failure callbacks recheck their captured identity on MainActor before changing authentication. +Same-installation token rotation remains valid. Five regressions cover retained reads/mutations, +receipts/intents, rotation and delayed failure callbacks; native execution remains pending. + +Retain the 120-minute native job allowance established during the A4 validation, with all tests, +the same device profiles and parallelism. This avoids truncating the expanding simulator journeys +and physical-device Release compilation; it does not treat a timeout or cached build as a pass. + +Use the official pinned Stripe PaymentSheet and per-attempt SDK state; Razorpay's native adapter +is compiled but remains unavailable until the server supplies its documented vendor configuration. +The current contract supports Stripe only. Receipts and credit notes open in native QuickLook/share; +PDF download stays on the generated authenticated same-server route, with bounded validated bytes, +no arbitrary URL fetch and no credentials forwarded to storage hosts. + +The explicit Debug payment preview never launches a payment SDK or creates a charge. It demonstrates +paid, awaiting confirmation, canceled, failed and late-confirmation journeys using separate per-maker +in-memory repositories. Native simulator/compiler results remain pending at this checkpoint; real +provider confirmation/webhooks, device SDK return paths and owner setup remain release gates. + +Parity: Payments / Review payment / Continue to payment / Payment pending / Paid online / Paid at the +desk / Refresh payment status / Receipts and credit notes / Share document / Done. Source adds22 +payment state/API cases and2 native payment journeys; these counts are intended, not evidence of a pass. + ## A4 Notifications and trusted routing — implementation checkpoint (2026-10-17) +Native build evidence (visual follow-up pending): [`a989989` CI38998510266](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/38998510266) passed 161 native state/API tests and 20 native UI journeys on both phone and tablet, 18 Python generation/fixture checks, and the unsigned physical-device Release build, with no failed or skipped tests. The phone before-submit screenshot was rejected because midpoint activation left the second label line below the screen. The stricter request-only visibility check is included with A5; its complete native rerun and fully visible phone screenshot remain required before visual acceptance. These are fixture/compiler results; real backend/provider/device validation remains a release gate. + Wire the native notification delegate, contextual permission UI, per-account device-only Keychain registration metadata and generated create/delete operations. Consent differs from OS permission; revoked permission pauses delivery and cleans up server IDs without erasing account consent. Token @@ -37,8 +81,10 @@ Schedule the request-review countdown only when a Retry-After deadline exists; t uses identical static submission controls and keeps the same busy, empty-cart and deadline guards. This removes needless scheduled work without claiming a proven cause for the phone scroll stall. Allow 120 minutes for the complete native suite and unsigned physical-device Release compilation; -all tests, profiles, gestures and strict visibility assertions remain enabled. Native acceptance -is still pending after the tablet passed and the phone request-review journey failed. +all tests, profiles and gestures remain enabled. Review buttons that fit vertically must be fully +inside visible content before native activation and the before-submit capture: a hittable midpoint +could leave the second line below the phone screen at the largest type size. Native and visual +acceptance of this stronger check remain pending. Parity: Notifications / Enable notifications / Notifications are off for this device. / Open system settings / Open update. Local generation/capability/source checks pass; native state/API/UI tests and From ce64d6b29b62c6c11c14422a04422a25e036cb05 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:46:06 +0530 Subject: [PATCH 33/98] fix(a5): expose session installation ownership check Co-Authored-By: Codex --- Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift | 1 + docs/CHANGELOG.md | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift index 1aeb4aa..b1b55df 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift @@ -110,6 +110,7 @@ public actor RefreshCoordinator { public func current() -> TokenPair? { pair } public func snapshot() -> CredentialSnapshot { CredentialSnapshot(pair: pair, epoch: generation) } public func isCurrent(_ snapshot: CredentialSnapshot) -> Bool { generation == snapshot.epoch && pair != nil } + public func isSameInstallation(_ snapshot: CredentialSnapshot) -> Bool { generation == snapshot.epoch } public func install(_ value: TokenPair?) throws { generation += 1 flight?.cancel() diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 79ade4d..1b6f4b7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -20,6 +20,10 @@ invalidate cached records before late callbacks can act. Bind member reads/actions and payments to the accepted device-session installation before their first executor hop. A retained repository cannot borrow a replacement account's credentials, and failure callbacks recheck their captured identity on MainActor before changing authentication. +Expose the actor's epoch-only installation comparison used by the bound repositories. It remains +available after a refused refresh clears credentials. The first A5 native checkpoint stopped at the +missing declaration before executing tests; the corrected complete native gate remains pending. + Same-installation token rotation remains valid. Five regressions cover retained reads/mutations, receipts/intents, rotation and delayed failure callbacks; native execution remains pending. From 072275f3c8459d63d77ceb092401185e5dd4b0a8 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:21:30 +0530 Subject: [PATCH 34/98] test(a5): activate native checkout dialog actions Co-Authored-By: Codex --- SpaceWorksUITests/PaymentsFlowTests.swift | 18 ++++++++++++++++-- docs/CHANGELOG.md | 7 +++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/SpaceWorksUITests/PaymentsFlowTests.swift b/SpaceWorksUITests/PaymentsFlowTests.swift index d6c8acb..3fa9782 100644 --- a/SpaceWorksUITests/PaymentsFlowTests.swift +++ b/SpaceWorksUITests/PaymentsFlowTests.swift @@ -21,12 +21,26 @@ final class PaymentsFlowTests: XCTestCase { tap(app.buttons["payments-entry"], in: app) return app } + private func chooseDemoOutcome(_ title: String, in app: XCUIApplication) { + // A system confirmation popover exposes separate title/action scroll areas. + // Its flattened AX descendants are not an app-content scroll hierarchy. + XCTAssertTrue(app.staticTexts["Demo checkout · no money moves"].waitForExistence(timeout: 5)) + let action = app.buttons[title] + XCTAssertTrue(action.waitForExistence(timeout: 5)) + XCTAssertTrue(action.isHittable) + let frame = action.frame + XCTAssertGreaterThan(frame.width, 0) + XCTAssertGreaterThan(frame.height, 0) + XCTAssertTrue(app.frame.insetBy(dx: 2, dy: 2).contains(frame)) + attach("Native demo checkout outcome") + action.tap() + } private func attach(_ name: String) { let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); attachment.name = name; attachment.lifetime = .keepAlways; add(attachment) } func testDelayedPaymentRemainsPendingUntilDemoServerConfirmationThenOpensReceipt() { let app = member(); tap(app.buttons["payment-501"], in: app) - tap(app.buttons["payment-continue"], in: app); tap(app.buttons["Preview waiting for confirmation"], in: app) + tap(app.buttons["payment-continue"], in: app); chooseDemoOutcome("Preview waiting for confirmation", in: app) let awaiting = app.descendants(matching: .any)["payment-awaiting"].firstMatch XCTAssertTrue(awaiting.waitForExistence(timeout: 10)); XCTAssertFalse(app.buttons["payment-continue"].exists) attach("Payment awaiting authoritative confirmation") @@ -39,7 +53,7 @@ final class PaymentsFlowTests: XCTestCase { } func testCancelledDemoCheckoutKeepsChargePendingAndAllowsExplicitRetry() { let app = member(); tap(app.buttons["payment-501"], in: app) - tap(app.buttons["payment-continue"], in: app); tap(app.buttons["Preview canceled checkout"], in: app) + tap(app.buttons["payment-continue"], in: app); chooseDemoOutcome("Preview canceled checkout", in: app) XCTAssertTrue(app.buttons["payment-continue"].waitForExistence(timeout: 10)) XCTAssertFalse(app.descendants(matching: .any)["payment-confirmed"].firstMatch.exists) attach("Payment cancelled with pending charge") diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1b6f4b7..57e19c4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -46,6 +46,13 @@ Parity: Payments / Review payment / Continue to payment / Payment pending / Paid desk / Refresh payment status / Receipts and credit notes / Share document / Done. Source adds22 payment state/API cases and2 native payment journeys; these counts are intended, not evidence of a pass. +The corrected compiler run executed all 188 native cases and all 20 inherited UI journeys on the +phone successfully. Both new payment journeys then stopped in the native outcome popover: its +separate title scroll area was incorrectly chosen by the generic app-content helper, despite the +action being fully visible and hittable. Payment tests now assert the actual dialog title and the +complete action frame, capture it, then use the native button activation. Shared scrolling and +payment behavior are unchanged. The complete corrected phone/tablet and Release gate is pending. + ## A4 Notifications and trusted routing — implementation checkpoint (2026-10-17) Native build evidence (visual follow-up pending): [`a989989` CI38998510266](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/38998510266) passed 161 native state/API tests and 20 native UI journeys on both phone and tablet, 18 Python generation/fixture checks, and the unsigned physical-device Release build, with no failed or skipped tests. The phone before-submit screenshot was rejected because midpoint activation left the second label line below the screen. The stricter request-only visibility check is included with A5; its complete native rerun and fully visible phone screenshot remain required before visual acceptance. These are fixture/compiler results; real backend/provider/device validation remains a release gate. From eaa400dcaafe8fb37baea93cfbb4f44924e108ad Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:04:53 +0530 Subject: [PATCH 35/98] feat(a6): add reviewed staff handover flows Co-Authored-By: Codex --- Fixtures/Staff/booking.json | 15 + Fixtures/Staff/checkin.json | 9 + Fixtures/Staff/containers.json | 4 + Fixtures/Staff/event.json | 43 +++ Fixtures/Staff/inventory.json | 19 + Fixtures/Staff/kits.json | 10 + Fixtures/Staff/loan.json | 32 ++ Fixtures/Staff/members.json | 12 + Fixtures/Staff/qr.json | 23 ++ Fixtures/Staff/registration.json | 14 + Fixtures/Staff/request.json | 45 +++ Fixtures/Staff/space.json | 31 ++ .../SpaceWorksAPI/StaffOperations.swift | 79 +++++ .../openapi-generator-config.yaml | 25 ++ SpaceWorks/DemoMode.swift | 13 +- SpaceWorks/MemberNavigation.swift | 2 +- SpaceWorks/MemberRoutes.swift | 4 + SpaceWorks/StaffCheckInState.swift | 50 +++ SpaceWorks/StaffDemo.swift | 146 ++++++++ SpaceWorks/StaffHandoverState.swift | 316 +++++++++++++++++ SpaceWorks/StaffHandoverView.swift | 223 ++++++++++++ SpaceWorks/StaffRepository.swift | 142 ++++++++ SpaceWorks/StaffState.swift | 173 ++++++++++ SpaceWorks/StaffViews.swift | 249 ++++++++++++++ SpaceWorksTests/StaffTests.swift | 324 ++++++++++++++++++ SpaceWorksUITests/PaymentsFlowTests.swift | 31 +- SpaceWorksUITests/StaffFlowTests.swift | 60 ++++ docs/CHANGELOG.md | 43 ++- scripts/build_staff_fixtures.py | 71 ++++ scripts/generate.py | 4 + scripts/generate_staff_fixtures.py | 16 + scripts/tests/test_staff_fixtures.py | 62 ++++ 32 files changed, 2284 insertions(+), 6 deletions(-) create mode 100644 Fixtures/Staff/booking.json create mode 100644 Fixtures/Staff/checkin.json create mode 100644 Fixtures/Staff/containers.json create mode 100644 Fixtures/Staff/event.json create mode 100644 Fixtures/Staff/inventory.json create mode 100644 Fixtures/Staff/kits.json create mode 100644 Fixtures/Staff/loan.json create mode 100644 Fixtures/Staff/members.json create mode 100644 Fixtures/Staff/qr.json create mode 100644 Fixtures/Staff/registration.json create mode 100644 Fixtures/Staff/request.json create mode 100644 Fixtures/Staff/space.json create mode 100644 Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/StaffOperations.swift create mode 100644 SpaceWorks/StaffCheckInState.swift create mode 100644 SpaceWorks/StaffDemo.swift create mode 100644 SpaceWorks/StaffHandoverState.swift create mode 100644 SpaceWorks/StaffHandoverView.swift create mode 100644 SpaceWorks/StaffRepository.swift create mode 100644 SpaceWorks/StaffState.swift create mode 100644 SpaceWorks/StaffViews.swift create mode 100644 SpaceWorksTests/StaffTests.swift create mode 100644 SpaceWorksUITests/StaffFlowTests.swift create mode 100644 scripts/build_staff_fixtures.py create mode 100644 scripts/generate_staff_fixtures.py create mode 100644 scripts/tests/test_staff_fixtures.py diff --git a/Fixtures/Staff/booking.json b/Fixtures/Staff/booking.json new file mode 100644 index 0000000..af295a0 --- /dev/null +++ b/Fixtures/Staff/booking.json @@ -0,0 +1,15 @@ +{ + "created_at": "2026-10-16T09:00:00Z", + "custom_answers": null, + "email": "meera@example.test", + "ends_at": "2026-10-16T09:00:00Z", + "id": 1001, + "name": "Meera Nair", + "note": "", + "payment": null, + "phone": "", + "public_token": "71000000-0000-4000-8000-000000000001", + "space_id": 901, + "starts_at": "2026-10-16T09:00:00Z", + "status": "pending" +} diff --git a/Fixtures/Staff/checkin.json b/Fixtures/Staff/checkin.json new file mode 100644 index 0000000..63165ec --- /dev/null +++ b/Fixtures/Staff/checkin.json @@ -0,0 +1,9 @@ +{ + "confirmable": true, + "event_status": "published", + "host_waiver_state": "not_required", + "name": "Meera Nair", + "payment_status": "not_required", + "registration_id": 1101, + "status": "registered" +} diff --git a/Fixtures/Staff/containers.json b/Fixtures/Staff/containers.json new file mode 100644 index 0000000..b377de7 --- /dev/null +++ b/Fixtures/Staff/containers.json @@ -0,0 +1,4 @@ +{ + "count": 0, + "results": [] +} diff --git a/Fixtures/Staff/event.json b/Fixtures/Staff/event.json new file mode 100644 index 0000000..f8506a2 --- /dev/null +++ b/Fixtures/Staff/event.json @@ -0,0 +1,43 @@ +{ + "cancellation_cutoff_hours": null, + "capacity": 20, + "category_id": null, + "created_at": "2026-10-16T09:00:00Z", + "created_by_id": null, + "custom_form": null, + "description": "", + "effective_registration_cutoff_at": null, + "ends_at": "2026-10-16T09:00:00Z", + "id": 801, + "image_url": null, + "is_public": false, + "location": "Sample workshop", + "location_kind": "indoor", + "makerspace_id": 7, + "offline_checkin_enabled": false, + "organizers": [], + "payment_amount": "0.00", + "registration_audience": "members", + "registration_counts": { + "attended": 0, + "cancelled": 0, + "pending_approval": 0, + "registered": 0, + "rejected": 0, + "waitlisted": 0 + }, + "registration_cutoff_at": null, + "registration_cutoff_lead_minutes": null, + "registration_open": false, + "registration_requires_approval": false, + "series_override_fields": null, + "series_revision": null, + "series_summary": null, + "starts_at": "2026-10-16T09:00:00Z", + "status": "published", + "tags": null, + "ticketing_features": [], + "timezone_name": "", + "title": "Sample repair workshop", + "updated_at": "2026-10-16T09:00:00Z" +} diff --git a/Fixtures/Staff/inventory.json b/Fixtures/Staff/inventory.json new file mode 100644 index 0000000..d17aa61 --- /dev/null +++ b/Fixtures/Staff/inventory.json @@ -0,0 +1,19 @@ +{ + "count": 1, + "results": [ + { + "created_at": "2026-10-16T09:00:00Z", + "id": 901, + "image_key": "", + "image_url": null, + "makerspace": 7, + "name": "Sample precision toolkit", + "needs_fix_quantity": 0, + "updated_at": "2026-10-16T09:00:00Z", + "tracking_mode": "quantity", + "available_quantity": 4, + "total_quantity": 4, + "is_archived": false + } + ] +} diff --git a/Fixtures/Staff/kits.json b/Fixtures/Staff/kits.json new file mode 100644 index 0000000..de429a2 --- /dev/null +++ b/Fixtures/Staff/kits.json @@ -0,0 +1,10 @@ +{ + "components": [ + { + "key": "72000000-0000-4000-8000-000000000001", + "label": "Sample probe", + "owner_label": "Precision toolkit", + "quantity": 2 + } + ] +} diff --git a/Fixtures/Staff/loan.json b/Fixtures/Staff/loan.json new file mode 100644 index 0000000..31f7f46 --- /dev/null +++ b/Fixtures/Staff/loan.json @@ -0,0 +1,32 @@ +{ + "container_id": 0, + "container_label": null, + "due_at": null, + "has_kit_checklist": false, + "id": 701, + "issue_evidence_id": null, + "issued_by": null, + "items": [ + { + "product_name": "Sample precision toolkit", + "quantity": 2 + } + ], + "public_token": "71000000-0000-4000-8000-000000000001", + "return_evidence_id": null, + "return_items": [ + { + "assets": [], + "item_id": 71, + "product_name": "Sample precision toolkit", + "remaining_quantity": 2, + "tracking_mode": "quantity" + } + ], + "return_notes": "", + "return_scan_required": false, + "source": "staff", + "status": "active", + "target_label": "Sample precision toolkit", + "target_type": "product" +} diff --git a/Fixtures/Staff/members.json b/Fixtures/Staff/members.json new file mode 100644 index 0000000..54b2386 --- /dev/null +++ b/Fixtures/Staff/members.json @@ -0,0 +1,12 @@ +{ + "count": 1, + "results": [ + { + "display_name": "Meera Nair", + "is_walk_in": false, + "membership_id": 81, + "user_id": 51, + "username": "meera" + } + ] +} diff --git a/Fixtures/Staff/qr.json b/Fixtures/Staff/qr.json new file mode 100644 index 0000000..e0201b2 --- /dev/null +++ b/Fixtures/Staff/qr.json @@ -0,0 +1,23 @@ +{ + "allowed_actions": [ + "view", + "contents", + "record_scan" + ], + "qr": { + "created_at": "2026-10-16T09:00:00Z", + "id": 1201, + "makerspace": 7, + "payload": "DEMO-BOX", + "revoked_at": null, + "status": "active", + "target_id": 91, + "target_type": "box", + "updated_at": "2026-10-16T09:00:00Z" + }, + "target": { + "type": "box", + "id": 91, + "label": "Sample kit box" + } +} diff --git a/Fixtures/Staff/registration.json b/Fixtures/Staff/registration.json new file mode 100644 index 0000000..33e8432 --- /dev/null +++ b/Fixtures/Staff/registration.json @@ -0,0 +1,14 @@ +{ + "created_at": "2026-10-16T09:00:00Z", + "custom_answers": null, + "discount_amount": "0.00", + "email": "meera@example.test", + "event_id": 801, + "id": 1101, + "name": "Meera Nair", + "payment": null, + "phone": "", + "quoted_amount": null, + "status": "registered", + "ticket_type_id": null +} diff --git a/Fixtures/Staff/request.json b/Fixtures/Staff/request.json new file mode 100644 index 0000000..c642de9 --- /dev/null +++ b/Fixtures/Staff/request.json @@ -0,0 +1,45 @@ +{ + "accepted_at": "2026-10-16T09:00:00Z", + "accepted_by": null, + "assigned_box_label": "Sample kit box", + "closed_at": "2026-10-16T09:00:00Z", + "created_at": "2026-10-16T09:00:00Z", + "has_kit_checklist": false, + "id": 601, + "issue_evidence_id": null, + "issued_at": "2026-10-16T09:00:00Z", + "issued_by": null, + "items": [ + { + "accepted_quantity": 2, + "damaged_quantity": 0, + "id": 61, + "issued_assets": [], + "issued_quantity": 2, + "missing_quantity": 0, + "needs_fix_quantity": 0, + "product_id": 901, + "product_name": "Sample precision toolkit", + "requested_quantity": 2, + "requires_asset_qr": false, + "returned_quantity": 0, + "storage_location": "Sample shelf A", + "tracking_mode": "quantity" + } + ], + "makerspace_id": 7, + "rejection_reason": "", + "renewal_count": 0, + "requested_for": "Sample electronics project", + "requester_contact_email": "meera@example.test", + "requester_contact_phone": "", + "requester_contact_verified": true, + "requester_display": "Meera Nair", + "requester_name": "Meera Nair", + "requester_username": "meera", + "return_due_at": "2026-10-16T09:00:00Z", + "return_evidence_ids": [], + "return_reminder_sent_at": "2026-10-16T09:00:00Z", + "status": "pending", + "updated_at": "2026-10-16T09:00:00Z" +} diff --git a/Fixtures/Staff/space.json b/Fixtures/Staff/space.json new file mode 100644 index 0000000..8f55f3e --- /dev/null +++ b/Fixtures/Staff/space.json @@ -0,0 +1,31 @@ +{ + "approval_mode": "instant", + "booking_lead_time_minutes": 0, + "buffer_after_minutes": 0, + "buffer_before_minutes": 0, + "cancellation_cutoff_hours": 0, + "capacity": 8, + "created_at": "2026-10-16T09:00:00Z", + "created_by_id": null, + "custom_form": null, + "description": "", + "effective_requester_notifications_enabled": false, + "id": 901, + "image_url": "", + "is_active": true, + "is_public": false, + "kind": "dev_room", + "location": "", + "machine_type": null, + "makerspace_id": 7, + "max_booking_advance_days": 0, + "max_booking_duration_minutes": 0, + "min_booking_duration_minutes": 0, + "name": "Sample project room", + "payment_amount": "0.00", + "public_token": "71000000-0000-4000-8000-000000000001", + "requester_notifications_enabled": null, + "show_public_availability": false, + "show_public_booker_names": false, + "updated_at": "2026-10-16T09:00:00Z" +} diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/StaffOperations.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/StaffOperations.swift new file mode 100644 index 0000000..43f13b7 --- /dev/null +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/StaffOperations.swift @@ -0,0 +1,79 @@ +import Foundation + +/// Staff API operations only. Native staff authority and visible actions are checked by the caller; +/// the server authorizes each generated operation. LiveStaffRepository binds the makerspace header. +extension SpaceWorksService { + public func staffPendingRequests(makerspaceID: Int, page: Int) async throws -> Components.Schemas.PaginatedAdminRequestList { + try await client.api_v1_admin_makerspace_pending_requests_list(.init(path: .init(makerspace_id: makerspaceID), query: .init(page: page))).ok.body.json + } + public func staffAcceptedRequests(makerspaceID: Int, page: Int) async throws -> Components.Schemas.PaginatedAdminRequestList { + try await client.api_v1_admin_makerspace_accepted_requests_list(.init(path: .init(makerspace_id: makerspaceID), query: .init(page: page))).ok.body.json + } + public func staffActiveLoans(makerspaceID: Int, page: Int) async throws -> Components.Schemas.PaginatedAdminRequestList { + try await client.api_v1_admin_makerspace_active_loans_list(.init(path: .init(makerspace_id: makerspaceID), query: .init(page: page))).ok.body.json + } + public func staffAcceptRequest(id: Int, body: Components.Schemas.AcceptRequest) async throws -> Components.Schemas.AdminRequest { + try await client.api_v1_admin_requests_accept_create(.init(path: .init(id: id), body: .json(body))).ok.body.json + } + public func staffRejectRequest(id: Int, body: Components.Schemas.RejectRequest) async throws -> Components.Schemas.AdminRequest { + try await client.api_v1_admin_requests_reject_create(.init(path: .init(id: id), body: .json(body))).ok.body.json + } + public func staffAssignBox(id: Int, body: Components.Schemas.AssignBox) async throws -> Components.Schemas.AdminRequest { + try await client.api_v1_admin_requests_assign_box_create(.init(path: .init(id: id), body: .json(body))).ok.body.json + } + public func staffIssueRequest(id: Int, body: Components.Schemas.IssueRequest) async throws -> Components.Schemas.AdminRequest { + try await client.api_v1_admin_requests_issue_create(.init(path: .init(id: id), body: .json(body))).ok.body.json + } + public func staffReturnRequest(id: Int, body: Components.Schemas.ReturnRequest) async throws -> Components.Schemas.AdminRequest { + try await client.api_v1_admin_requests_return_create(.init(path: .init(id: id), body: .json(body))).ok.body.json + } + public func staffResolveQR(body: Components.Schemas.QrResolve) async throws -> Components.Schemas.QrResolveResult { + try await client.api_v1_admin_qr_resolve_create(.init(body: .json(body))).ok.body.json + } + public func staffDirectLoans(makerspaceID: Int, page: Int) async throws -> Components.Schemas.PaginatedDirectLoanList { + try await client.api_v1_admin_makerspace_direct_loans_list(.init(path: .init(makerspace_id: makerspaceID), query: .init(page: page))).ok.body.json + } + public func staffDirectMembers(makerspaceID: Int, page: Int) async throws -> Components.Schemas.PaginatedDirectLoanMemberList { + try await client.api_v1_admin_makerspace_direct_loan_members_list(.init(path: .init(makerspace_id: makerspaceID), query: .init(page: page))).ok.body.json + } + public func staffInventory(makerspaceID: Int, page: Int, search: String) async throws -> Components.Schemas.PaginatedInventoryProductAdminList { + try await client.api_v1_admin_makerspace_inventory_list(.init(path: .init(makerspace_id: makerspaceID), query: .init(page: page, q: search.isEmpty ? nil : search))).ok.body.json + } + public func staffContainers(makerspaceID: Int, page: Int) async throws -> Components.Schemas.PaginatedBoxList { + try await client.api_v1_admin_makerspace_containers_list(.init(path: .init(makerspace_id: makerspaceID), query: .init(page: page))).ok.body.json + } + public func staffCreateDirectLoan(makerspaceID: Int, body: Components.Schemas.DirectLoanIssue) async throws -> Components.Schemas.DirectLoan { + try await client.api_v1_admin_makerspace_direct_loans_create(.init(path: .init(makerspace_id: makerspaceID), body: .json(body))).created.body.json + } + public func staffReturnDirectLoan(id: Int, body: Components.Schemas.DirectLoanReturn) async throws -> Components.Schemas.DirectLoan { + try await client.api_v1_admin_direct_loans_return_create(.init(path: .init(id: id), body: .json(body))).ok.body.json + } + public func staffEvidence(makerspaceID: Int, body: Components.Schemas.EvidenceUrlRequest) async throws -> Components.Schemas.EvidenceUrlResponse { + try await client.api_v1_admin_makerspaces_uploads_evidence_url_create(.init(path: .init(makerspace_id: makerspaceID), body: .json(body))).created.body.json + } + public func staffKitPreview(makerspaceID: Int, body: Components.Schemas.KitPreviewInput) async throws -> Components.Schemas.KitPreview { + try await client.api_v1_admin_makerspace_kit_checklist_preview_create(.init(path: .init(makerspace_id: makerspaceID), body: .json(body))).ok.body.json + } + public func staffEvents(makerspaceID: Int, page: Int) async throws -> Components.Schemas.EventListWithDefaults { + try await client.api_v1_admin_makerspaces_events_retrieve(.init(path: .init(makerspace_id: makerspaceID), query: .init(page: page))).ok.body.json + } + /// Resolving identifies a registration. It never marks attendance automatically. + public func staffResolveCheckIn(eventID: Int, body: Components.Schemas.EventCheckInResolveRequest) async throws -> Components.Schemas.EventCheckInResolveResponse { + try await client.api_v1_admin_events_check_in_resolve_create(.init(path: .init(id: eventID), body: .json(body))).ok.body.json + } + public func staffMarkAttended(registrationID: Int, body: Components.Schemas.EventAttendanceMark) async throws -> Components.Schemas.EventRegistrationAdmin { + try await client.api_v1_admin_event_registrations_mark_attended_create(.init(path: .init(id: registrationID), body: .json(body))).ok.body.json + } + public func staffSpaces(makerspaceID: Int, page: Int) async throws -> Components.Schemas.BookableSpaceListResponse { + try await client.api_v1_admin_makerspaces_spaces_retrieve(.init(path: .init(makerspace_id: makerspaceID), query: .init(page: page))).ok.body.json + } + public func staffBookings(spaceID: Int, page: Int) async throws -> Components.Schemas.BookingListResponse { + try await client.api_v1_admin_spaces_bookings_retrieve(.init(path: .init(id: spaceID), query: .init(page: page))).ok.body.json + } + public func staffApproveBooking(id: Int) async throws -> Components.Schemas.BookingAdmin { + try await client.api_v1_admin_bookings_approve_create(.init(path: .init(id: id))).ok.body.json + } + public func staffRejectBooking(id: Int) async throws -> Components.Schemas.BookingAdmin { + try await client.api_v1_admin_bookings_reject_create(.init(path: .init(id: id))).ok.body.json + } +} diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml index 53b57cc..3cd7cdb 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml @@ -53,3 +53,28 @@ filter: - api_v1_member_makerspaces_payment_documents_retrieve - api_v1_member_makerspaces_payment_documents_download_url_retrieve - api_v1_payment_documents_download_retrieve + + - api_v1_admin_makerspace_pending_requests_list + - api_v1_admin_makerspace_accepted_requests_list + - api_v1_admin_makerspace_active_loans_list + - api_v1_admin_requests_accept_create + - api_v1_admin_requests_reject_create + - api_v1_admin_requests_assign_box_create + - api_v1_admin_requests_issue_create + - api_v1_admin_requests_return_create + - api_v1_admin_qr_resolve_create + - api_v1_admin_makerspace_direct_loans_list + - api_v1_admin_makerspace_direct_loan_members_list + - api_v1_admin_makerspace_inventory_list + - api_v1_admin_makerspace_containers_list + - api_v1_admin_makerspace_direct_loans_create + - api_v1_admin_direct_loans_return_create + - api_v1_admin_makerspaces_uploads_evidence_url_create + - api_v1_admin_makerspace_kit_checklist_preview_create + - api_v1_admin_makerspaces_events_retrieve + - api_v1_admin_events_check_in_resolve_create + - api_v1_admin_event_registrations_mark_attended_create + - api_v1_admin_makerspaces_spaces_retrieve + - api_v1_admin_spaces_bookings_retrieve + - api_v1_admin_bookings_approve_create + - api_v1_admin_bookings_reject_create diff --git a/SpaceWorks/DemoMode.swift b/SpaceWorks/DemoMode.swift index 30f471f..4a734d3 100644 --- a/SpaceWorks/DemoMode.swift +++ b/SpaceWorks/DemoMode.swift @@ -22,6 +22,7 @@ final class DemoSession { var selectedSpace = 0 var notificationState = DemoNotificationState() @ObservationIgnored private var paymentRepositories: [Int: DemoPaymentRepository] = [:] + var staffScenario = DemoStaffScenario.populated var actionScenario = DemoMemberActionsRepository.Scenario.success var profileDisplayName: String? var memberScenario = DemoMemberRepository.Scenario.populated @@ -126,6 +127,7 @@ struct DemoAuthView: View { Picker("Demo role", selection: $session.role) { ForEach(DemoSession.Role.allCases, id: \.self) { Text($0.rawValue) } } .pickerStyle(.segmented).accessibilityIdentifier("demo-role") Picker("Sign-in preview", selection: $session.scenario) { ForEach(DemoSession.Scenario.allCases, id: \.self) { Text($0.rawValue) } } + Picker("Staff data preview", selection: $session.staffScenario) { ForEach(DemoStaffScenario.allCases, id: \.self) { Text($0.rawValue) } } Picker("App data preview", selection: $session.memberScenario) { ForEach(DemoMemberRepository.Scenario.allCases, id: \.self) { Text($0.rawValue) } } Button("Continue as \(session.role.rawValue.lowercased())") { session.stage = .signIn }.buttonStyle(SWPrimary()) case .signIn: @@ -259,10 +261,13 @@ struct DemoShell: View { @State private var drafts: MemberActionDrafts @State private var capabilities: MemberCapabilities @State private var payments: PaymentsState + @State private var staff: StaffState? @State private var actions: DemoMemberActionsRepository @State private var columns = NavigationSplitViewVisibility.automatic init(session: DemoSession, linkRouter: MobileLinkRouter) { self.session = session; self.linkRouter = linkRouter + _staff = State(initialValue: session.role == .staff ? StaffState(repository: DemoStaffRepository(scenario: session.staffScenario, makerspaceID: session.selectedSpace + 7), makerspaceID: session.selectedSpace + 7, + actions: ["accept_request", "reject_request", "assign_box", "issue_request", "return_request", "upload_evidence", "issue_direct_loan", "manage_events", "manage_bookings"], features: ["loans.kit_checklist"]) : nil) _payments = State(initialValue: PaymentsState(repository: session.paymentRepository(for: session.selectedSpace + 7), isDemo: true, sleep: { _ in })) let store = DemoMemberStore() let actions = DemoMemberActionsRepository(store: store, scenario: session.actionScenario, name: session.name) @@ -300,7 +305,7 @@ struct DemoShell: View { }.onChange(of: geometry.size.width, initial: true) { _, width in columns = width >= 1000 ? .all : .detailOnly } - }.task { async let config: Void = capabilities.load(); await member.loadUnreadCount(); await config }.onDisappear { member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate() } + }.task { async let config: Void = capabilities.load(); await member.loadUnreadCount(); await config }.onDisappear { member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); staff?.invalidate() } .task(id: linkRouter.approved?.id) { if let approved = linkRouter.approved, approved.link.makerspaceID == session.selectedSpace + 7 { await MobileLinkDestination.open(approved, router: linkRouter, member: member, navigation: navigation, drafts: drafts, payment: { id in @@ -313,9 +318,9 @@ struct DemoShell: View { } private func destination(_ tab: AppTab) -> some View { NavigationStack(path: navigation.path(tab)) { - DemoDestination(session: session, member: member, tab: tab, actions: actions, cart: cart, navigation: navigation, capabilities: capabilities, drafts: drafts, payments: payments, linkRouter: linkRouter) + DemoDestination(session: session, member: member, tab: tab, actions: actions, cart: cart, navigation: navigation, capabilities: capabilities, drafts: drafts, payments: payments, staff: staff, linkRouter: linkRouter) .navigationDestination(for: MemberRoute.self) { route in - MemberRouteView(route: route, member: member, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, isDemo: true, payments: payments) { + MemberRouteView(route: route, member: member, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, isDemo: true, payments: payments, staff: staff) { if let profile = try? await actions.profile() { session.profileDisplayName = profile.display_name } } } @@ -332,6 +337,7 @@ struct DemoDestination: View { let capabilities: MemberCapabilities let drafts: MemberActionDrafts let payments: PaymentsState + let staff: StaffState? let linkRouter: MobileLinkRouter var body: some View { Group { @@ -351,6 +357,7 @@ struct DemoDestination: View { if let error = capabilities.error { SWError(message: error); Button("Retry account options") { Task { await capabilities.load() } }.frame(minHeight: 48) } if capabilities.selfService { NavigationLink("Edit profile", value: MemberRoute.contact).frame(minHeight: 48) } if capabilities.preferences { NavigationLink("Communication preferences", value: MemberRoute.preferences).frame(minHeight: 48) } + if staff != nil { NavigationLink("Staff tools", value: MemberRoute.staff).frame(minHeight: 48).accessibilityIdentifier("staff-tools") } NavigationLink("Payments", value: MemberRoute.payments).frame(minHeight: 48).accessibilityIdentifier("payments-entry") NavigationLink("Notifications") { DemoNotificationsView(state: session.notificationState, makerspaceID: session.selectedSpace + 7) { link in diff --git a/SpaceWorks/MemberNavigation.swift b/SpaceWorks/MemberNavigation.swift index 7a1a535..8634a97 100644 --- a/SpaceWorks/MemberNavigation.swift +++ b/SpaceWorks/MemberNavigation.swift @@ -1,7 +1,7 @@ import SwiftUI enum MemberRoute: Hashable { - case product(Int), spaces, space(String), event(String), booking(String), registration(Int), contact, preferences, payments, payment(Int) + case product(Int), spaces, space(String), event(String), booking(String), registration(Int), contact, preferences, payments, payment(Int), staff } /// Memory-only navigation belongs to one authenticated account/makerspace, outside adaptive branches. diff --git a/SpaceWorks/MemberRoutes.swift b/SpaceWorks/MemberRoutes.swift index 5eaf9cb..e460c71 100644 --- a/SpaceWorks/MemberRoutes.swift +++ b/SpaceWorks/MemberRoutes.swift @@ -12,6 +12,7 @@ struct MemberRouteView: View { let capabilities: MemberCapabilities let isDemo: Bool var payments: PaymentsState? = nil + var staff: StaffState? = nil var profileSaved: () async -> Void = {} var body: some View { switch route { @@ -27,6 +28,9 @@ struct MemberRouteView: View { } else { missing("Event unavailable", load: member.eventsLoad) { await member.loadEvents() }.task { if member.eventsLoad == .idle { await member.loadEvents() } } } case .booking(let token): BookingDestination(member: member, repository: actions, token: token, isDemo: isDemo) case .registration(let id): RegistrationDestination(member: member, repository: actions, id: id, isDemo: isDemo, drafts: drafts) + case .staff: + if let staff { StaffView(state: staff) } + else { disabledFeature("Staff tools require an authorized native staff session.") } case .payments: if let payments { PaymentsView(state: payments) } else { disabledFeature("Payments are unavailable. Try again later.") } diff --git a/SpaceWorks/StaffCheckInState.swift b/SpaceWorks/StaffCheckInState.swift new file mode 100644 index 0000000..1368a52 --- /dev/null +++ b/SpaceWorks/StaffCheckInState.swift @@ -0,0 +1,50 @@ +import Foundation +import Observation +import SpaceWorksAPI + +@MainActor @Observable +final class StaffCheckInState { + let parent: StaffState + private let eventID: Int + private let eventMakerspaceID: Int + private(set) var eventTitle: String + private(set) var person: Components.Schemas.EventCheckInResolveResponse? + private(set) var busy = false + private(set) var error: String? + private(set) var confirmation: String? + private var generation = 0 + private var active = true + init(parent: StaffState, event: Components.Schemas.EventAdmin) { + self.parent = parent; self.eventID = event.id; self.eventMakerspaceID = event.makerspace_id; self.eventTitle = event.title + parent.registerChild { [weak self] in self?.invalidate() } + } + var canConfirm: Bool { active && parent.canRequest && parent.can("manage_events") && !busy && person?.status == "registered" && person?.confirmable == true } + func invalidate() { active = false; generation += 1; person = nil; confirmation = nil; error = nil; busy = false; eventTitle = "" } + func resolve(_ token: String) async { + guard active, parent.canRequest, parent.can("manage_events"), !busy, eventMakerspaceID == parent.makerspaceID else { return } + generation += 1; let current = generation; person = nil; confirmation = nil; error = nil + let value = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, value.utf8.count <= 2048, !value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { error = "Enter a valid check-in code."; return } + busy = true + do { + let resolved = try await parent.repository.resolveCheckIn(eventID: eventID, body: .init(checkin_token: value)) + guard active, current == generation else { return }; person = resolved; busy = false + } catch { + guard active, current == generation else { return }; busy = false; parent.refuse(error) + self.error = APIError.status(error) == 404 ? "That is not a check-in code for this event." : APIError.display(error) + } + } + /// Called only by the separate visible confirmation button, never by the camera callback. + func confirm() async { + guard canConfirm, let person else { return }; busy = true; error = nil + let current = generation + do { + _ = try await parent.repository.markAttended(registrationID: person.registration_id, body: .init(source: .init(value1: .qr))) + guard active, current == generation else { return } + confirmation = "Attendance recorded for \(person.name)."; self.person = nil; busy = false + } catch { + guard active, current == generation else { return }; self.person = nil; busy = false; parent.refuse(error) + self.error = APIError.status(error) == 409 ? "This registration changed. Scan the code again to check its latest status." : APIError.display(error) + } + } +} diff --git a/SpaceWorks/StaffDemo.swift b/SpaceWorks/StaffDemo.swift new file mode 100644 index 0000000..c68f98a --- /dev/null +++ b/SpaceWorks/StaffDemo.swift @@ -0,0 +1,146 @@ +#if DEBUG +import Foundation +import SpaceWorksAPI + +enum DemoStaffScenario: String, CaseIterable, Sendable { + case populated = "Populated", empty = "Empty", offline = "Error", uploadRefused = "Photo refused" + case issueConflict = "Issue conflict", waitlisted = "Waitlisted check-in", attendanceConflict = "Check-in changed" + case bookingConflict = "Booking changed" +} + +/// Entirely local sample records. No live gateway, payment SDK, scanner mutation or upload is used. +actor DemoStaffRepository: StaffRepository { + nonisolated let isDemo = true + let scenario: DemoStaffScenario + let makerspaceID: Int + private var statuses = [601: "pending", 602: "accepted", 603: "issued"] + private var assignments = Set() + private var directCreated = false + private var directReturned = false + private var attended = false + private var bookingStatus = "pending" + private var evidenceCounter = 1300 + private let beforeEvents: @Sendable () async -> Void + private let beforeEvidence: @Sendable () async -> Void + init(scenario: DemoStaffScenario = .populated, makerspaceID: Int = 7, beforeEvents: @escaping @Sendable () async -> Void = {}, + beforeEvidence: @escaping @Sendable () async -> Void = {}) { + self.scenario = scenario; self.makerspaceID = makerspaceID; self.beforeEvents = beforeEvents; self.beforeEvidence = beforeEvidence + } + private func checked() throws { if scenario == .offline { throw URLError(.notConnectedToInternet) } } + private func decode(_ json: String, changes: [String: Any] = [:]) throws -> T { + try checked() + var object = try JSONSerialization.jsonObject(with: Data(json.utf8)) as! [String: Any] + if object["makerspace_id"] != nil { object["makerspace_id"] = makerspaceID } + object.merge(changes) { _, new in new } + let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(T.self, from: JSONSerialization.data(withJSONObject: object)) + } + private func page(_ rows: [Row], page: Int, extra: [String: Any] = [:]) throws -> T { + let encoder = JSONEncoder(); encoder.dateEncodingStrategy = .iso8601 + let values = try JSONSerialization.jsonObject(with: encoder.encode(rows)) + var object: [String: Any] = ["count": scenario == .empty ? 0 : rows.count, "next": NSNull(), "previous": NSNull(), "results": scenario == .empty || page > 1 ? [] : values] + object.merge(extra) { _, new in new } + return try decode(String(decoding: JSONSerialization.data(withJSONObject: object), as: UTF8.self)) + } + func requests(queue: StaffQueue, page: Int) throws -> Components.Schemas.PaginatedAdminRequestList { + let target = queue == .pending ? "pending" : queue == .accepted ? "accepted" : "issued" + let rows: [Components.Schemas.AdminRequest] = try statuses.keys.sorted().filter { statuses[$0] == target }.map { try request($0) } + return try self.page(rows, page: page) + } + private func request(_ id: Int) throws -> Components.Schemas.AdminRequest { + guard let status = statuses[id] else { throw APIError(status: 404, code: "not_found", detail: "Sample request unavailable.") } + var object = try JSONSerialization.jsonObject(with: Data(DebugStaffFixtures.request.utf8)) as! [String: Any] + var items = object["items"] as! [[String: Any]] + items[0]["issued_quantity"] = status == "issued" || status == "closed" ? 2 : 0 + items[0]["returned_quantity"] = status == "closed" ? 2 : 0 + object["items"] = items + return try decode(String(decoding: JSONSerialization.data(withJSONObject: object), as: UTF8.self), changes: ["id": id, "status": status]) + } + func accept(id: Int, body: Components.Schemas.AcceptRequest) throws -> Components.Schemas.AdminRequest { + try checked(); statuses[id] = "accepted"; return try request(id) + } + func reject(id: Int, body: Components.Schemas.RejectRequest) throws -> Components.Schemas.AdminRequest { + try checked(); statuses[id] = "rejected"; return try request(id) + } + func assignBox(id: Int, body: Components.Schemas.AssignBox) throws -> Components.Schemas.AdminRequest { + try checked(); guard statuses[id] == "accepted", body.box_code == "DEMO-BOX" else { throw APIError(status: 409, code: "changed", detail: "Sample container or request changed.") } + assignments.insert(id); return try request(id) + } + func issue(id: Int, body: Components.Schemas.IssueRequest) throws -> Components.Schemas.AdminRequest { + try checked() + if scenario == .issueConflict { throw APIError(status: 409, code: "issue_conflict", detail: "The request changed before issue completed.") } + guard statuses[id] == "accepted", assignments.contains(id) else { throw APIError(status: 409, code: "changed", detail: "Refresh the sample request.") } + statuses[id] = "issued"; return try request(id) + } + func returnRequest(id: Int, body: Components.Schemas.ReturnRequest) throws -> Components.Schemas.AdminRequest { + try checked(); statuses[id] = "closed"; return try request(id) + } + func resolveQR(body: Components.Schemas.QrResolve) throws -> Components.Schemas.QrResolveResult { + guard ["DEMO-BOX", "DEMO-TOOL"].contains(body.payload) else { throw APIError(status: 404, code: "not_found", detail: "Use DEMO-BOX or DEMO-TOOL in this sample.") } + var object = try JSONSerialization.jsonObject(with: Data(DebugStaffFixtures.qr.utf8)) as! [String: Any] + if body.payload == "DEMO-TOOL" { + var qr = object["qr"] as! [String: Any]; qr["id"] = 1202; qr["payload"] = body.payload; qr["target_type"] = "product"; qr["target_id"] = 901; object["qr"] = qr + object["target"] = ["type": "product", "id": 901, "label": "Sample precision toolkit", "product_id": 901] + object["allowed_actions"] = ["view", "direct_handout"] + } + return try decode(String(decoding: JSONSerialization.data(withJSONObject: object), as: UTF8.self)) + } + func directLoans(page: Int) throws -> Components.Schemas.PaginatedDirectLoanList { + let row: Components.Schemas.DirectLoan = try decode(DebugStaffFixtures.loan, changes: ["status": directReturned ? "returned" : "active"]) + return try self.page([row], page: page) + } + func directMembers(page: Int) throws -> Components.Schemas.PaginatedDirectLoanMemberList { + let initial: Components.Schemas.PaginatedDirectLoanMemberList = try decode(DebugStaffFixtures.members) + return try self.page(initial.results, page: page) + } + func inventory(page: Int, search: String) throws -> Components.Schemas.PaginatedInventoryProductAdminList { + let initial: Components.Schemas.PaginatedInventoryProductAdminList = try decode(DebugStaffFixtures.inventory) + return try self.page(initial.results.filter { search.isEmpty || $0.name.localizedCaseInsensitiveContains(search) }, page: page) + } + func containers(page: Int) throws -> Components.Schemas.PaginatedBoxList { try decode(DebugStaffFixtures.containers) } + func createDirect(body: Components.Schemas.DirectLoanIssue) throws -> Components.Schemas.DirectLoan { + try checked(); directCreated = true; return try decode(DebugStaffFixtures.loan) + } + func returnDirect(id: Int, body: Components.Schemas.DirectLoanReturn) throws -> Components.Schemas.DirectLoan { + try checked(); directReturned = true; return try decode(DebugStaffFixtures.loan, changes: ["status": "returned"]) + } + func evidence(body: Components.Schemas.EvidenceUrlRequest) async throws -> Components.Schemas.EvidenceUrlResponse { + await beforeEvidence() + try checked() + if scenario == .uploadRefused { throw APIError(status: 403, code: "upload_refused", detail: "The sample evidence upload was refused.") } + evidenceCounter += 1 + return try decode(#"{"evidence_id":0,"upload_url":"https://storage.example.invalid/demo-only","object_key":"demo-only","fields":{}}"#, changes: ["evidence_id": evidenceCounter]) + } + func kitPreview(body: Components.Schemas.KitPreviewInput) throws -> Components.Schemas.KitPreview { try decode(DebugStaffFixtures.kits) } + func events(page: Int) async throws -> Components.Schemas.EventListWithDefaults { + await beforeEvents() + let row: Components.Schemas.EventAdmin = try decode(DebugStaffFixtures.event) + return try self.page([row], page: page, extra: ["default_registration_audience": "members"]) + } + func resolveCheckIn(eventID: Int, body: Components.Schemas.EventCheckInResolveRequest) throws -> Components.Schemas.EventCheckInResolveResponse { + guard eventID == 801, body.checkin_token == "DEMO-EVENT" else { throw APIError(status: 404, code: "not_found", detail: "That is not a check-in code for this event.") } + let status = attended ? "attended" : scenario == .waitlisted ? "waitlisted" : "registered" + return try decode(DebugStaffFixtures.checkin, changes: ["status": status, "confirmable": status == "registered"]) + } + func markAttended(registrationID: Int, body: Components.Schemas.EventAttendanceMark) throws -> Components.Schemas.EventRegistrationAdmin { + try checked() + if scenario == .attendanceConflict { throw APIError(status: 409, code: "changed", detail: "This registration changed.") } + guard registrationID == 1101, !attended, scenario != .waitlisted else { throw APIError(status: 409, code: "changed", detail: "This registration changed.") } + attended = true; return try decode(DebugStaffFixtures.registration, changes: ["status": "attended"]) + } + func spaces(page: Int) throws -> Components.Schemas.BookableSpaceListResponse { + let row: Components.Schemas.BookableSpaceAdmin = try decode(DebugStaffFixtures.space) + return try self.page([row], page: page) + } + func bookings(spaceID: Int, page: Int) throws -> Components.Schemas.BookingListResponse { + let row: Components.Schemas.BookingAdmin = try decode(DebugStaffFixtures.booking, changes: ["status": bookingStatus]) + return try self.page(spaceID == row.space_id ? [row] : [], page: page) + } + func approveBooking(id: Int) throws -> Components.Schemas.BookingAdmin { + try checked(); bookingStatus = "confirmed" + if scenario == .bookingConflict { throw APIError(status: 409, code: "changed", detail: "This booking changed. Refresh its status.") } + return try decode(DebugStaffFixtures.booking, changes: ["status": bookingStatus]) + } + func rejectBooking(id: Int) throws -> Components.Schemas.BookingAdmin { try checked(); bookingStatus = "rejected"; return try decode(DebugStaffFixtures.booking, changes: ["status": bookingStatus]) } +} +#endif diff --git a/SpaceWorks/StaffHandoverState.swift b/SpaceWorks/StaffHandoverState.swift new file mode 100644 index 0000000..277086c --- /dev/null +++ b/SpaceWorks/StaffHandoverState.swift @@ -0,0 +1,316 @@ +import Foundation +import CoreFoundation +import Observation +import SpaceWorksAPI + +enum StaffMetadata { + static func positiveID(_ value: Any?) -> Int? { + guard let value, let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), let id = value as? Int, id > 0 else { return nil } + return id + } + static func remaining(issued: Int, returned: Int, damaged: Int, missing: Int) -> Int? { + guard issued >= 0 else { return nil } + var remaining = issued + for count in [returned, damaged, missing] { + guard count >= 0, count <= remaining else { return nil } + remaining -= count + } + return remaining + } +} + +enum StaffHandoverMode { + case issue(Components.Schemas.AdminRequest), returnRequest(Components.Schemas.AdminRequest) + case directIssue, directReturn(Components.Schemas.DirectLoan) + var isReturn: Bool { switch self { case .returnRequest, .directReturn: return true; default: return false } } + var request: Components.Schemas.AdminRequest? { switch self { case .issue(let row), .returnRequest(let row): return row; default: return nil } } + var loan: Components.Schemas.DirectLoan? { if case .directReturn(let row) = self { return row }; return nil } + var isDirect: Bool { switch self { case .directIssue, .directReturn: return true; default: return false } } + var title: String { switch self { case .issue: return "Issue equipment"; case .returnRequest, .directReturn: return "Return equipment"; case .directIssue: return "Direct handout" } } +} + +/// Editable return counts are UI state. Only generated ReturnItemResolution is sent to the API. +struct StaffReturnDraft { + let id: Int + let label: String + let remaining: Int + let assets: [(id: Int, label: String)] + var metadataValid = true + var returned: Int + var damaged = 0 + var missing = 0 + var outcomes: [Int: String] = [:] + var valid: Bool { + metadataValid && remaining >= 0 && [returned, damaged, missing].allSatisfy { $0 >= 0 } && + returned <= remaining && damaged <= remaining - returned && missing <= remaining - returned - damaged + } + var body: Components.Schemas.ReturnItemResolution { + let values: [Components.Schemas.ReturnAssetResolution]? = assets.isEmpty ? nil : assets.compactMap { asset in + guard let raw = outcomes[asset.id], let outcome = Components.Schemas.ReturnAssetResolutionOutcomeEnum(rawValue: raw) else { return nil } + return .init(asset_id: asset.id, outcome: outcome) + } + return .init(item_id: id, returned: returned, damaged: damaged, missing: missing, assets: values) + } + mutating func setOutcome(assetID: Int, value: String) { + guard assets.contains(where: { $0.id == assetID }) else { return } + outcomes[assetID] = value == "pending" ? nil : value + returned = outcomes.values.filter { $0 == "returned" }.count + damaged = outcomes.values.filter { $0 == "damaged" }.count + missing = outcomes.values.filter { $0 == "missing" }.count + } +} + +@MainActor @Observable +final class StaffHandoverState { + private(set) var mode: StaffHandoverMode + let parent: StaffState + var step = 0 + var borrowerID: Int? + var borrowerChecked = false + var remark = "" + var bulkQuantities: [Int: Int] = [:] { didSet { kitsReady = false; kitCounts = [:] } } + var returns: [StaffReturnDraft] = [] { didSet { kitsReady = false; kitCounts = [:] } } + var kitCounts: [String: Int] = [:] + private(set) var members: [Components.Schemas.DirectLoanMember] = [] + private(set) var products: [Components.Schemas.InventoryProductAdmin] = [] + private(set) var membersNext: Int? + private(set) var productsNext: Int? + private(set) var boxCode = "" + private(set) var boxLabel = "" + private(set) var scans: [String: Components.Schemas.QrResolveResult] = [:] + private(set) var kits: [Components.Schemas.KitExpectation] = [] + private(set) var kitsReady = false + private(set) var photo: Data? + private(set) var evidenceID: Int? + private(set) var busy = false + private(set) var error: String? + private(set) var notice: String? + private(set) var receipt: String? + private(set) var assignmentRecorded = false + private(set) var needsReload = false + private var active = true + private var generation = 0 + private let upload: @Sendable (Components.Schemas.EvidenceUrlResponse, Data) async throws -> Void + init(parent: StaffState, mode: StaffHandoverMode, + upload: @escaping @Sendable (Components.Schemas.EvidenceUrlResponse, Data) async throws -> Void = StaffEvidenceUpload.send) { + self.parent = parent; self.mode = mode; self.upload = upload + if let row = mode.request, mode.isReturn { + returns = row.items.map { item in + let assets: [(id: Int, label: String)] = item.issued_assets.compactMap { value in + guard let data = try? JSONEncoder().encode(value), + let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let id = StaffMetadata.positiveID(object["asset_id"]) else { return nil } + return (id, object["asset_tag"] as? String ?? "Asset #\(id)") + } + let remaining = StaffMetadata.remaining(issued: item.issued_quantity, returned: item.returned_quantity, damaged: item.damaged_quantity, missing: item.missing_quantity) + let metadataValid = remaining != nil && (!item.requires_asset_qr || remaining == 0 || (!assets.isEmpty && assets.count == item.issued_assets.count && Set(assets.map(\.id)).count == assets.count)) + return StaffReturnDraft(id: item.id, label: item.product_name, remaining: remaining ?? 0, assets: assets, + metadataValid: metadataValid, returned: 0) + } + } else if let row = mode.loan { + returns = row.return_items.map { item in + let assets = item.assets.map { (id: $0.asset_id, label: $0.asset_tag) } + return StaffReturnDraft(id: item.item_id, label: item.product_name, remaining: item.remaining_quantity, + assets: assets, metadataValid: item.remaining_quantity >= 0 && assets.allSatisfy({ $0.id > 0 }) && Set(assets.map(\.id)).count == assets.count && (item.tracking_mode != "individual" || item.remaining_quantity == 0 || !assets.isEmpty), returned: 0) + } + } + parent.registerChild { [weak self] in self?.invalidate() } + } + var isDemo: Bool { parent.isDemo } + var canRequest: Bool { active && parent.canRequest && !busy } + var steps: [String] { + if case .directIssue = mode { return ["Borrower", "Equipment", "Photo and review", "Receipt"] } + if mode.isReturn { return ["Container", "Returned items", "Photo and review", "Receipt"] } + return ["Container", "Borrower", "Equipment", "Photo and review", "Receipt"] + } + var reviewStep: Int { steps.count - 2 } + var requiresBox: Bool { !mode.isDirect || (mode.loan?.return_scan_required ?? false) } + var needsKits: Bool { parent.features.contains("loans.kit_checklist") || mode.request?.has_kit_checklist == true || mode.loan?.has_kit_checklist == true } + var selectedItems: [Components.Schemas.DirectLoanItem] { + products.filter { (bulkQuantities[$0.id] ?? 0) > 0 }.map { .init(product_id: $0.id, quantity: bulkQuantities[$0.id] ?? 0) } + } + var duplicateProducts: Set { + Set(scans.values.compactMap { result -> Int? in + let product = StaffMetadata.positiveID(result.target.additionalProperties["product_id"]?.value) + ?? (result.qr.target_type.rawValue == "product" ? result.qr.target_id : nil) + guard let product, (bulkQuantities[product] ?? 0) > 0 else { return nil }; return product + }) + } + var kitBody: [Components.Schemas.KitCount]? { + needsKits ? kits.map { .init(key: $0.key, present: kitCounts[$0.key] ?? 0) } : nil + } + var itemsReady: Bool { + if mode.isReturn { return returns.allSatisfy(\.valid) && returns.contains { $0.returned > 0 || $0.damaged > 0 || $0.missing > 0 } } + if let row = mode.request { + return row.items.contains(where: { $0.accepted_quantity > 0 }) && row.items.filter(\.requires_asset_qr).allSatisfy { item in + scans.values.filter { StaffMetadata.positiveID($0.target.additionalProperties["product_id"]?.value) == item.product_id }.count == item.accepted_quantity + } + } + let validBulk = bulkQuantities.allSatisfy { id, quantity in + guard let product = products.first(where: { $0.id == id }) else { return quantity == 0 } + return quantity >= 0 && quantity <= (product.available_quantity ?? 0) && product.tracking_mode?.rawValue != "individual" && product.is_archived != true + } + return validBulk && duplicateProducts.isEmpty && (!selectedItems.isEmpty || !scans.isEmpty) + } + var ready: Bool { + guard canRequest, !needsReload, mode.request?.makerspace_id == nil || mode.request?.makerspace_id == parent.makerspaceID, + evidenceID != nil, itemsReady, !requiresBox || !boxCode.isEmpty, + !needsKits || (kitsReady && kits.allSatisfy({ $0.quantity >= 0 && (0...$0.quantity).contains(kitCounts[$0.key] ?? -1) })) else { return false } + if mode.isReturn { return !remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + if case .directIssue = mode { return members.contains { $0.user_id == borrowerID } } + return borrowerChecked + } + func invalidate() { active = false; generation += 1; photo = nil; evidenceID = nil; scans = [:]; members = []; products = []; returns = []; receipt = nil; error = nil; mode = .directIssue; boxCode = ""; boxLabel = ""; remark = ""; borrowerID = nil; kits = []; kitCounts = [:] } + func loadBorrowers(more: Bool = false) async { + guard canRequest, !more || membersNext != nil else { return }; busy = true + let page = more ? membersNext! : 1 + do { + let result = try await parent.repository.directMembers(page: page); guard active else { return } + membersNext = try StaffPaging.next(result.next, after: page) + members = StaffPaging.unique((more ? members : []) + result.results, id: { $0.user_id }); busy = false + } catch { fail(error) } + } + func loadProducts(more: Bool = false) async { + guard canRequest, !more || productsNext != nil else { return }; busy = true + let page = more ? productsNext! : 1 + do { + let result = try await parent.repository.inventory(page: page, search: ""); guard active else { return } + productsNext = try StaffPaging.next(result.next, after: page) + products = StaffPaging.unique((more ? products : []) + result.results, id: { $0.id }); busy = false + } catch { fail(error) } + } + func scan(_ raw: String, box: Bool) async { + guard canRequest, !assignmentRecorded else { return } + let value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, value.utf8.count <= 2048, !value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { error = "Enter a valid QR payload."; return } + busy = true; error = nil + do { + let result = try await parent.repository.resolveQR(body: .init(payload: value)); guard active else { return } + guard result.qr.makerspace == parent.makerspaceID, result.qr.status.value1.rawValue == "active" else { throw validation("Scan an active code from this makerspace.") } + let canonical = result.qr.payload + guard !canonical.isEmpty, canonical.utf8.count <= 64, + !canonical.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { throw validation("This QR code cannot be used for a handover.") } + let type = result.qr.target_type.rawValue + if box { + if let loan = mode.loan { + guard type == loan.target_type else { throw validation("Scan the equipment code for this loan.") } + if type == "box" { guard result.qr.target_id == loan.container_id else { throw validation("Scan the container for this loan.") } } + if type == "asset" { guard loan.return_items.flatMap(\.assets).contains(where: { $0.asset_id == result.qr.target_id }) else { throw validation("Scan an asset from this loan.") } } + } else { guard type == "box" else { throw validation("Scan the container for this handover.") } } + boxCode = canonical; boxLabel = result.target.additionalProperties["label"]?.value as? String ?? "Container #\(result.qr.target_id)" + } else { + guard type != "box" else { throw validation("Scan an equipment or asset code here.") } + if mode.request != nil { + guard type == "asset", result.target.additionalProperties["status"]?.value as? String == "available", + let product = StaffMetadata.positiveID(result.target.additionalProperties["product_id"]?.value), + mode.request?.items.contains(where: { $0.product_id == product && $0.requires_asset_qr }) == true else { throw validation("Scan an available asset from this request.") } + } else { guard result.allowed_actions.contains("direct_handout") else { throw validation("This equipment is unavailable for direct handout.") } } + if scans.values.contains(where: { $0.qr.id == result.qr.id }) { throw validation("This code has already been added.") } + guard scans.count < 50 else { throw validation("A handover can contain up to 50 equipment codes.") } + scans[canonical] = result + } + busy = false; kitsReady = false + } catch { fail(error) } + } + func removeScan(_ value: String) { guard !busy else { return }; scans[value] = nil; kitsReady = false } + func prepareKits() async { + guard canRequest, itemsReady else { return } + if !needsKits { kits = []; kitsReady = true; return } + busy = true; error = nil; kitsReady = false + do { + guard let phase = Components.Schemas.PhaseEnum(rawValue: mode.isReturn ? "return" : "issue") else { throw validation("Checklist phase unavailable.") } + let result = try await parent.repository.kitPreview(body: .init( + phase: .init(value1: phase), request_id: mode.request?.id, + loan_id: mode.loan?.id, box_code: boxCode.isEmpty ? nil : boxCode, + container_id: nil, qr_payloads: Array(scans.keys).sorted(), items: mode.isDirect && !mode.isReturn ? selectedItems : nil, + resolutions: mode.isReturn ? returns.map(\.body) : nil)) + guard active else { return }; kits = result.components + kitCounts = [:] + kitsReady = true; busy = false + } catch { fail(error) } + } + func setPhoto(_ data: Data) { + guard active, !busy, receipt == nil, !needsReload else { return } + generation += 1; evidenceID = nil; error = nil + photo = data.count <= 5 * 1024 * 1024 && data.starts(with: [0xff, 0xd8, 0xff]) ? data : nil + if photo == nil { error = "Choose a JPEG photo up to 5 MB." } + } + func uploadPhoto() async { + guard canRequest, let photo else { return }; busy = true; evidenceID = nil; error = nil + let current = generation + do { + guard let type = Components.Schemas.EvidenceUrlRequestEvidenceTypeEnum(rawValue: mode.isReturn ? "return" : "issue") else { throw validation("Photo type unavailable.") } + let grant = try await parent.repository.evidence(body: .init(evidence_type: type, content_type: "image/jpeg", size_bytes: photo.count)) + guard active, current == generation else { return } + if !isDemo { try await upload(grant, photo) } + guard active, current == generation else { return } + evidenceID = grant.evidence_id; busy = false + } catch { guard active, current == generation else { return }; fail(error) } + } + func confirm() async { + guard ready, let evidenceID else { return } + let permission = mode.isReturn ? "return_request" : (mode.isDirect ? "issue_direct_loan" : "issue_request") + guard parent.can(permission), parent.can("upload_evidence"), mode.isDirect || mode.isReturn || parent.can("assign_box") else { error = "You no longer have permission for this handover."; return } + busy = true; error = nil + do { + let id: Int + switch mode { + case .issue(let row): + if !assignmentRecorded { + _ = try await parent.repository.assignBox(id: row.id, body: .init(box_code: boxCode)); guard active else { return } + assignmentRecorded = true + } + let result = try await parent.repository.issue(id: row.id, body: .init(kit_checklist: kitBody, evidence_id: evidenceID, + remark: remark, asset_qr_payloads: Array(scans.keys).sorted())) + id = result.id + case .returnRequest(let row): + id = try await parent.repository.returnRequest(id: row.id, body: .init(kit_checklist: kitBody, evidence_id: evidenceID, + box_code: boxCode, remark: remark.trimmingCharacters(in: .whitespacesAndNewlines), resolutions: returns.map(\.body))).id + case .directIssue: + guard let borrowerID else { throw validation("Choose a borrower.") } + id = try await parent.repository.createDirect(body: .init(kit_checklist: kitBody, borrower_id: borrowerID, + evidence_id: evidenceID, remark: remark, qr_payloads: Array(scans.keys).sorted(), items: selectedItems)).id + case .directReturn(let row): + id = try await parent.repository.returnDirect(id: row.id, body: .init(kit_checklist: kitBody, evidence_id: evidenceID, + notes: remark.trimmingCharacters(in: .whitespacesAndNewlines), qr_payload: boxCode.isEmpty ? nil : boxCode, resolutions: returns.map(\.body))).id + } + guard active else { return }; receipt = "\(mode.isReturn ? "Return" : "Issue") #\(id) recorded" + notice = "Evidence photo #\(evidenceID) is attached."; busy = false; step = steps.count - 1 + await parent.loadQueue(); await parent.loadDirect() + } catch { + guard active else { return }; needsReload = true; fail(error) + if assignmentRecorded { notice = "Box assignment was recorded; issue still needs to complete. Check the latest request before retrying." } + } + } + private func validation(_ detail: String) -> APIError { APIError(status: 0, code: "handover_validation", detail: detail) } + private func fail(_ error: Error) { + guard active else { return }; self.error = APIError.display(error); busy = false; parent.refuse(error) + if [401, 403].contains(APIError.status(error)) { invalidate() } + } +} + +enum StaffEvidenceUpload { + static func send(_ grant: Components.Schemas.EvidenceUrlResponse, _ photo: Data) async throws { + guard let url = URL(string: grant.upload_url) else { throw NativeTransferError.invalidURL } + func strings(_ value: T) throws -> [String: String] { + let object = try JSONSerialization.jsonObject(with: JSONEncoder().encode(value)) + guard let fields = object as? [String: Any] else { throw NativeTransferError.invalidInput } + var result: [String: String] = [:] + for (key, value) in fields { + if let text = value as? String { result[key] = text } + else if let number = value as? NSNumber { + result[key] = CFGetTypeID(number) == CFBooleanGetTypeID() ? (number.boolValue ? "true" : "false") : number.stringValue + } else { throw NativeTransferError.invalidInput } + } + return result + } + let transfer = NativeFileTransfer() + if grant.method?.uppercased() == "PUT" { + try await transfer.uploadPUT(to: url, headers: grant.headers.map { try strings($0) } ?? [:], photo: photo, contentType: "image/jpeg", maximumBytes: 5 * 1024 * 1024) + } else { + guard grant.method == nil || grant.method?.uppercased() == "POST" else { throw NativeTransferError.invalidInput } + try await transfer.uploadPOST(to: url, fields: strings(grant.fields), photo: photo, contentType: "image/jpeg", filename: "evidence.jpg", maximumBytes: 5 * 1024 * 1024) + } + } +} diff --git a/SpaceWorks/StaffHandoverView.swift b/SpaceWorks/StaffHandoverView.swift new file mode 100644 index 0000000..2dc4cdb --- /dev/null +++ b/SpaceWorks/StaffHandoverView.swift @@ -0,0 +1,223 @@ +import SwiftUI +import SpaceWorksAPI +import UIKit + +struct StaffHandoverView: View { + @State var state: StaffHandoverState + @State private var scanner: ScanKind? + @State private var camera = false + @State private var cameraMessage: String? + @State private var confirm = false + @Environment(\.dismiss) private var dismiss + private enum ScanKind: String, Identifiable { case box, equipment; var id: String { rawValue } } + private var directIssue: Bool { if case .directIssue = state.mode { return true }; return false } + private var currentTitle: String { state.steps[min(state.step, state.steps.count - 1)] } + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + Text("Step \(state.step + 1) of \(state.steps.count)").font(.subheadline).foregroundStyle(Color.swMuted) + ProgressView(value: Double(state.step + 1), total: Double(state.steps.count)).tint(Color.swAccentInk).accessibilityLabel("Handover progress") + Text(currentTitle).font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + if state.isDemo { Label("Demo handover · no live equipment changes", systemImage: "info.circle").foregroundStyle(Color.swMuted) } + if let error = state.error { SWError(message: error).accessibilityIdentifier("staff-handover-error") } + if let notice = state.notice { Label(notice, systemImage: "info.circle").foregroundStyle(Color.swMuted) } + if state.busy { ProgressView("Working…") } + if !state.parent.isActive { + SWError(message: state.parent.error ?? "Your access changed. Return to your makerspace before continuing.") + Button("Close") { dismiss() }.buttonStyle(SWPrimary()) + } else if let receipt = state.receipt { + SWCard { + Label(receipt, systemImage: "checkmark.circle").font(.title2.bold()).accessibilityIdentifier("staff-handover-recorded") + if let evidence = state.evidenceID { Text("Evidence photo #\(evidence)") } + if !state.remark.isEmpty { Text(state.remark) } + Text("This summary reflects the recorded handover. Refresh the loan to see its latest status.").foregroundStyle(Color.swMuted) + } + Button("Done") { dismiss() }.buttonStyle(SWPrimary()) + } else if state.needsReload { + Text("Check the latest request before starting another handover. A failed response may need reconciliation.").foregroundStyle(Color.swMuted) + Button("Close and refresh") { Task { await state.parent.loadQueue(); await state.parent.loadDirect(); dismiss() } }.buttonStyle(SWPrimary()) + } else { + stepContent.disabled(state.busy) + if state.step > 0 { Button("Back") { state.step -= 1 }.frame(minHeight: 48).disabled(state.busy || state.assignmentRecorded) } + if state.step < state.reviewStep { + Button("Next") { + state.step += 1 + if state.step == state.reviewStep { Task { await state.prepareKits() } } + }.buttonStyle(SWPrimary()).disabled(!canAdvance || state.busy).accessibilityIdentifier("staff-handover-next") + } else { + Button(state.mode.isReturn ? "Confirm return" : "Confirm handout") { confirm = true } + .buttonStyle(SWPrimary()).disabled(!state.ready).accessibilityIdentifier("staff-handover-confirm") + } + } + }.padding(24).frame(maxWidth: 760).frame(maxWidth: .infinity) + }.background(Color.swBg).foregroundStyle(Color.swInk).tint(Color.swAccentInk) + .navigationTitle(state.mode.title).navigationBarTitleDisplayMode(.inline) + .interactiveDismissDisabled(state.busy) + .navigationBarBackButtonHidden(state.busy) + .task { + if directIssue { await state.loadBorrowers(); await state.loadProducts() } + } + .sheet(item: $scanner) { kind in + NativeQRScanner(title: kind == .box ? "Check handover code" : "Add equipment") { value in + scanner = nil; Task { await state.scan(value, box: kind == .box) } + } + } + .fullScreenCover(isPresented: $camera) { + NativeEvidenceCamera(onPhoto: { result in + camera = false + switch result { case .success(let photo): state.setPhoto(photo); case .failure: cameraMessage = "This photo could not be prepared. Take another photo." } + }, onCancel: { camera = false }).ignoresSafeArea() + } + .confirmationDialog(state.mode.isReturn ? "Record this return?" : "Record this handout?", isPresented: $confirm, titleVisibility: .visible) { + Button("Record handover") { Task { await state.confirm() } } + Button("Cancel", role: .cancel) {} + } message: { Text("Check the borrower, equipment, counts, and evidence before recording the handover.") } + } + @ViewBuilder private var stepContent: some View { + if state.step == state.reviewStep { review } + else if directIssue && state.step == 0 { borrowers } + else if directIssue || (!state.mode.isReturn && state.step == 2) { equipment } + else if !state.mode.isReturn && state.step == 1 { borrowerCheck } + else if state.mode.isReturn && state.step == 1 { returnedItems } + else { container } + } + private var canAdvance: Bool { + if directIssue && state.step == 0 { return state.members.contains { $0.user_id == state.borrowerID } } + if directIssue || (!state.mode.isReturn && state.step == 2) || (state.mode.isReturn && state.step == 1) { return state.itemsReady } + if !state.mode.isReturn && state.step == 1 { return state.borrowerChecked } + return !state.requiresBox || !state.boxCode.isEmpty + } + private var container: some View { + SWCard { + Text(state.mode.loan == nil ? "Check the container" : "Check the loan code").font(.headline) + Text(state.requiresBox ? "Scan the code attached to this handover. The server will verify it belongs to this makerspace." : "This loan does not require a return scan.").foregroundStyle(Color.swMuted) + if state.requiresBox { Button(state.boxCode.isEmpty ? "Scan handover code" : "Scan another code") { scanner = .box }.frame(minHeight: 48) } + if !state.boxLabel.isEmpty { Label(state.boxLabel, systemImage: "checkmark.circle") } + } + } + private var borrowerCheck: some View { + SWCard { + if let row = state.mode.request { + Text(row.requester_display.isEmpty ? row.requester_username : row.requester_display).font(.title2.bold()) + Text("Request #\(row.id)").foregroundStyle(Color.swMuted) + Text("Check the borrower's membership card or approved identification before handing over the equipment.").foregroundStyle(Color.swMuted) + Toggle("I checked the borrower against this request", isOn: $state.borrowerChecked).frame(minHeight: 48).accessibilityIdentifier("staff-borrower-checked") + } + } + } + private var borrowers: some View { + SWCard { + Text("Choose an eligible borrower").font(.headline) + Text("These people are supplied by this makerspace. Selecting a person does not create a sign-in account.").foregroundStyle(Color.swMuted) + Picker("Borrower", selection: $state.borrowerID) { + Text("Choose a borrower").tag(Optional.none) + ForEach(state.members, id: \.user_id) { member in Text(member.display_name.isEmpty ? member.username : member.display_name).tag(Optional(member.user_id)) } + }.pickerStyle(.menu).frame(minHeight: 48).accessibilityIdentifier("staff-borrower") + if state.membersNext != nil { Button("Load more borrowers") { Task { await state.loadBorrowers(more: true) } }.frame(minHeight: 48) } + if state.members.isEmpty && !state.busy { Button("Reload borrowers") { Task { await state.loadBorrowers() } }.frame(minHeight: 48) } + } + } + private var equipment: some View { + VStack(alignment: .leading, spacing: 24) { + if let row = state.mode.request { + ForEach(row.items, id: \.id) { item in SWCard { Text(item.product_name).font(.headline); Text("Accepted: \(item.accepted_quantity)"); if item.requires_asset_qr { Text("Scan each individual asset.").foregroundStyle(Color.swMuted) } } } + } else { + ForEach(state.products.filter { $0.is_archived != true && ($0.available_quantity ?? 0) > 0 && $0.tracking_mode?.rawValue != "individual" }, id: \.id) { item in + SWCard { + Text(item.name).font(.headline) + Stepper("Quantity: \(state.bulkQuantities[item.id] ?? 0)", value: Binding(get: { state.bulkQuantities[item.id] ?? 0 }, set: { state.bulkQuantities[item.id] = $0 }), in: 0...max(0, item.available_quantity ?? 0)).frame(minHeight: 48).accessibilityLabel("Quantity for \(item.name)").accessibilityValue("\(state.bulkQuantities[item.id] ?? 0)") + } + } + if state.productsNext != nil { Button("Load more equipment") { Task { await state.loadProducts(more: true) } }.frame(minHeight: 48) } + if state.products.isEmpty && !state.busy { Button("Reload equipment") { Task { await state.loadProducts() } }.frame(minHeight: 48) } + } + Button("Scan equipment code") { scanner = .equipment }.frame(minHeight: 48).disabled(state.busy) + Text("\(state.scans.count) equipment codes checked").font(.subheadline).foregroundStyle(Color.swMuted) + if !state.duplicateProducts.isEmpty { SWError(message: "This equipment is both scanned and selected by quantity. Remove one selection before continuing.") } + ForEach(state.scans.keys.sorted(), id: \.self) { payload in + HStack(alignment: .top) { + Text(state.scans[payload]?.target.additionalProperties["label"]?.value as? String ?? "Equipment code added").frame(maxWidth: .infinity, alignment: .leading) + Button("Remove") { state.removeScan(payload) }.frame(minHeight: 48).accessibilityLabel("Remove \(state.scans[payload]?.target.additionalProperties["label"]?.value as? String ?? "equipment code")") + } + } + } + } + private var returnedItems: some View { + ForEach(state.returns.indices, id: \.self) { index in + SWCard { + Text(state.returns[index].label).font(.headline) + Text("Remaining: \(state.returns[index].remaining)").foregroundStyle(Color.swMuted) + if !state.returns[index].metadataValid { SWError(message: "Asset details are unavailable. Refresh this loan before recording a return.") } + else if state.returns[index].assets.isEmpty { + countStepper("Returned", index: index, keyPath: \.returned) + countStepper("Damaged", index: index, keyPath: \.damaged) + countStepper("Missing", index: index, keyPath: \.missing) + } else { + ForEach(state.returns[index].assets, id: \.id) { asset in + Picker(asset.label, selection: Binding(get: { state.returns[index].outcomes[asset.id] ?? "pending" }, set: { state.returns[index].setOutcome(assetID: asset.id, value: $0) })) { + Text("Not returned now").tag("pending"); Text("Returned").tag("returned"); Text("Damaged").tag("damaged"); Text("Missing").tag("missing") + }.pickerStyle(.menu).frame(minHeight: 48) + } + } + if !state.returns[index].valid { Text("Check that these counts do not exceed the remaining quantity.").foregroundStyle(Color.swDanger) } + } + } + } + private func countStepper(_ title: String, index: Int, keyPath: WritableKeyPath) -> some View { + Stepper("\(title): \(state.returns[index][keyPath: keyPath])", value: Binding(get: { state.returns[index][keyPath: keyPath] }, set: { state.returns[index][keyPath: keyPath] = $0 }), in: 0...max(0, state.returns[index].remaining)).frame(minHeight: 48).accessibilityLabel("\(title) \(state.returns[index].label)").accessibilityValue("\(state.returns[index][keyPath: keyPath])") + } + private var review: some View { + VStack(alignment: .leading, spacing: 24) { + SWCard { + Text("Evidence photo").font(.headline) + Text("Photograph the equipment and its condition. Avoid faces or unrelated personal information.").foregroundStyle(Color.swMuted) + if let photo = state.photo, let image = UIImage(data: photo) { Image(uiImage: image).resizable().scaledToFit().frame(maxHeight: 280).clipShape(RoundedRectangle(cornerRadius: 16)).accessibilityLabel("Evidence photo preview") } + Button(state.photo == nil ? "Take evidence photo" : "Retake photo") { Task { await prepareCamera() } }.frame(minHeight: 48).disabled(state.busy) + #if DEBUG + if state.isDemo { Button("Use sample evidence photo") { samplePhoto() }.frame(minHeight: 48).accessibilityIdentifier("staff-sample-photo") } + #endif + if let cameraMessage { Text(cameraMessage).foregroundStyle(Color.swMuted) } + if let evidence = state.evidenceID { Label("Photo uploaded · #\(evidence)", systemImage: "checkmark.circle") } + else if state.photo != nil { Button("Upload evidence photo") { Task { await state.uploadPhoto() } }.frame(minHeight: 48).disabled(state.busy).accessibilityIdentifier("staff-upload-photo") } + } + if state.needsKits { + SWCard { + Text("Kit checklist").font(.headline) + Text("Count each component. Enter zero for a missing component.").foregroundStyle(Color.swMuted) + if !state.kitsReady { Button("Load kit checklist") { Task { await state.prepareKits() } }.frame(minHeight: 48).disabled(state.busy) } + ForEach(state.kits, id: \.key) { item in + VStack(alignment: .leading, spacing: 8) { + Text("\(item.owner_label) · \(item.label)") + if state.kitCounts[item.key] == nil { Button("Start count") { state.kitCounts[item.key] = 0 }.frame(minHeight: 48).accessibilityLabel("Start count for \(item.owner_label), \(item.label)") } + else { Stepper("Present: \(state.kitCounts[item.key] ?? 0) of \(item.quantity)", value: Binding(get: { state.kitCounts[item.key] ?? 0 }, set: { state.kitCounts[item.key] = $0 }), in: 0...max(0, item.quantity)).frame(minHeight: 48).accessibilityLabel("Present \(item.owner_label), \(item.label)").accessibilityValue("\(state.kitCounts[item.key] ?? 0) of \(item.quantity)") } + } + } + } + } + Text(state.mode.isReturn ? "Return remark (required)" : "Issue remark (optional)").font(.headline) + TextField("Describe the equipment condition", text: $state.remark, axis: .vertical).lineLimit(3...8).modifier(StaffInput()).accessibilityIdentifier("staff-handover-remark") + if state.mode.isReturn { ForEach(state.returns, id: \.id) { item in Text("\(item.label): \(item.returned) returned, \(item.damaged) damaged, \(item.missing) missing").foregroundStyle(Color.swMuted) } } + Text("Confirming records this handover. Review the details before continuing.").foregroundStyle(Color.swMuted) + } + } + private func prepareCamera() async { + do { + switch try await NativeCameraAccess.request() { + case .ready: cameraMessage = nil; camera = true + case .denied: cameraMessage = "Allow Camera in Settings to take an evidence photo." + case .restricted: cameraMessage = "Camera access is restricted on this device. Ask an administrator or use another device." + case .unavailable: cameraMessage = "This device has no available camera. Use a device with a camera to record this handover." + } + } catch { cameraMessage = "Camera request cancelled." } + } + #if DEBUG + private func samplePhoto() { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 640, height: 480)) + let image = renderer.image { context in + UIColor.white.setFill(); context.fill(CGRect(x: 0, y: 0, width: 640, height: 480)) + "DEMO EVIDENCE · SAMPLE DATA".draw(at: CGPoint(x: 40, y: 200), withAttributes: [.font: UIFont.systemFont(ofSize: 24), .foregroundColor: UIColor.black]) + } + if let data = try? NativePhotoPreparation.jpeg(from: image) { state.setPhoto(data) } + } + #endif +} diff --git a/SpaceWorks/StaffRepository.swift b/SpaceWorks/StaffRepository.swift new file mode 100644 index 0000000..5fcf410 --- /dev/null +++ b/SpaceWorks/StaffRepository.swift @@ -0,0 +1,142 @@ +import Foundation +import SpaceWorksAPI + +/// Local navigation selection; never serialized as an API request. +enum StaffQueue: String, CaseIterable, Sendable { case pending, accepted, active } + +protocol StaffRepository: Sendable { + var isDemo: Bool { get } + func requests(queue: StaffQueue, page: Int) async throws -> Components.Schemas.PaginatedAdminRequestList + func accept(id: Int, body: Components.Schemas.AcceptRequest) async throws -> Components.Schemas.AdminRequest + func reject(id: Int, body: Components.Schemas.RejectRequest) async throws -> Components.Schemas.AdminRequest + func assignBox(id: Int, body: Components.Schemas.AssignBox) async throws -> Components.Schemas.AdminRequest + func issue(id: Int, body: Components.Schemas.IssueRequest) async throws -> Components.Schemas.AdminRequest + func returnRequest(id: Int, body: Components.Schemas.ReturnRequest) async throws -> Components.Schemas.AdminRequest + func resolveQR(body: Components.Schemas.QrResolve) async throws -> Components.Schemas.QrResolveResult + func directLoans(page: Int) async throws -> Components.Schemas.PaginatedDirectLoanList + func directMembers(page: Int) async throws -> Components.Schemas.PaginatedDirectLoanMemberList + func inventory(page: Int, search: String) async throws -> Components.Schemas.PaginatedInventoryProductAdminList + func containers(page: Int) async throws -> Components.Schemas.PaginatedBoxList + func createDirect(body: Components.Schemas.DirectLoanIssue) async throws -> Components.Schemas.DirectLoan + func returnDirect(id: Int, body: Components.Schemas.DirectLoanReturn) async throws -> Components.Schemas.DirectLoan + func evidence(body: Components.Schemas.EvidenceUrlRequest) async throws -> Components.Schemas.EvidenceUrlResponse + func kitPreview(body: Components.Schemas.KitPreviewInput) async throws -> Components.Schemas.KitPreview + func events(page: Int) async throws -> Components.Schemas.EventListWithDefaults + func resolveCheckIn(eventID: Int, body: Components.Schemas.EventCheckInResolveRequest) async throws -> Components.Schemas.EventCheckInResolveResponse + func markAttended(registrationID: Int, body: Components.Schemas.EventAttendanceMark) async throws -> Components.Schemas.EventRegistrationAdmin + func spaces(page: Int) async throws -> Components.Schemas.BookableSpaceListResponse + func bookings(spaceID: Int, page: Int) async throws -> Components.Schemas.BookingListResponse + func approveBooking(id: Int) async throws -> Components.Schemas.BookingAdmin + func rejectBooking(id: Int) async throws -> Components.Schemas.BookingAdmin +} +extension StaffRepository { var isDemo: Bool { false } } + +/// Immutable scope for every read and mutation, including ID-only API routes. +/// Construct only after native_staff authority and the selected membership's actions are verified. +struct LiveStaffRepository: StaffRepository { + let service: SpaceWorksService + let expectedCredentials: CredentialSnapshot? + let makerspaceID: Int + let origin: ServerOrigin + var onUnauthorized: @Sendable (String) async -> Void = { _ in } + var onForbidden: @Sendable () async -> Void = {} + + private func checked(_ work: @Sendable () async throws -> T) async throws -> T { + guard let expectedCredentials, await service.refresh.isSameInstallation(expectedCredentials) else { throw CancellationError() } + guard makerspaceID > 0 else { throw CancellationError() } + do { return try await service.withMakerspace(makerspaceID, expectedCredentials: expectedCredentials, operation: work) } + catch { + if Task.isCancelled || APIError.isCancellation(error) { throw error } + guard await service.refresh.isSameInstallation(expectedCredentials) else { throw CancellationError() } + let empty = await service.refresh.current() == nil + if APIError.status(error) == 401 || empty { await onUnauthorized(APIError.display(error)) } + else if APIError.status(error) == 403 { await onForbidden() } + throw error + } + } + /// Links are checked, never fetched. The state parses the actual page and calls this repository again. + private func validate(_ next: String?, path: String, after page: Int) throws { + guard let next else { return } + guard next.utf8.count <= 8192, !next.contains("\\"), + !next.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }), + let base = URL(string: path, relativeTo: origin.url)?.absoluteURL, + let absolute = URL(string: next, relativeTo: base)?.absoluteURL, + let validated = try origin.nextPage(absolute.absoluteString), + let parts = URLComponents(url: validated, resolvingAgainstBaseURL: true), + parts.percentEncodedPath == path, let items = parts.queryItems, items.count == 1, + items[0].name == "page", let raw = items[0].value, !raw.isEmpty, + raw.allSatisfy({ $0.isASCII && $0.isNumber }), let number = Int(raw), number > page else { + throw APIError(status: 0, code: "invalid_next_page", detail: "The next staff page is unavailable. Refresh and try again.") + } + } + func requests(queue: StaffQueue, page: Int) async throws -> Components.Schemas.PaginatedAdminRequestList { + let result: Components.Schemas.PaginatedAdminRequestList + let suffix: String + switch queue { + case .pending: + result = try await checked { try await service.staffPendingRequests(makerspaceID: makerspaceID, page: page) }; suffix = "pending-requests" + case .accepted: + result = try await checked { try await service.staffAcceptedRequests(makerspaceID: makerspaceID, page: page) }; suffix = "accepted-requests" + case .active: + result = try await checked { try await service.staffActiveLoans(makerspaceID: makerspaceID, page: page) }; suffix = "active-loans" + } + try validate(result.next, path: "/api/v1/admin/makerspace/\(makerspaceID)/\(suffix)", after: page) + return result + } + func accept(id: Int, body: Components.Schemas.AcceptRequest) async throws -> Components.Schemas.AdminRequest { try await checked { try await service.staffAcceptRequest(id: id, body: body) } } + func reject(id: Int, body: Components.Schemas.RejectRequest) async throws -> Components.Schemas.AdminRequest { try await checked { try await service.staffRejectRequest(id: id, body: body) } } + func assignBox(id: Int, body: Components.Schemas.AssignBox) async throws -> Components.Schemas.AdminRequest { try await checked { try await service.staffAssignBox(id: id, body: body) } } + func issue(id: Int, body: Components.Schemas.IssueRequest) async throws -> Components.Schemas.AdminRequest { try await checked { try await service.staffIssueRequest(id: id, body: body) } } + func returnRequest(id: Int, body: Components.Schemas.ReturnRequest) async throws -> Components.Schemas.AdminRequest { try await checked { try await service.staffReturnRequest(id: id, body: body) } } + func resolveQR(body: Components.Schemas.QrResolve) async throws -> Components.Schemas.QrResolveResult { try await checked { try await service.staffResolveQR(body: body) } } + func directLoans(page: Int) async throws -> Components.Schemas.PaginatedDirectLoanList { + let result = try await checked { try await service.staffDirectLoans(makerspaceID: makerspaceID, page: page) } + try validate(result.next, path: "/api/v1/admin/makerspace/\(makerspaceID)/direct-loans", after: page); return result + } + func directMembers(page: Int) async throws -> Components.Schemas.PaginatedDirectLoanMemberList { + let result = try await checked { try await service.staffDirectMembers(makerspaceID: makerspaceID, page: page) } + try validate(result.next, path: "/api/v1/admin/makerspace/\(makerspaceID)/direct-loan-members", after: page); return result + } + func inventory(page: Int, search: String) async throws -> Components.Schemas.PaginatedInventoryProductAdminList { + let result = try await checked { try await service.staffInventory(makerspaceID: makerspaceID, page: page, search: search) } + // Inventory next links may retain the generated search query; no URL is ever used as a request. + try validateInventory(result.next, page: page, search: search); return result + } + private func validateInventory(_ next: String?, page: Int, search: String) throws { + guard let next else { return } + guard var parts = URLComponents(string: next) else { + throw APIError(status: 0, code: "invalid_next_page", detail: "The next staff page is unavailable. Refresh and try again.") + } + let query = parts.queryItems ?? [] + guard query.filter({ $0.name == "q" }).count <= 1, + query.allSatisfy({ $0.name == "page" || ($0.name == "q" && $0.value == search) }) else { + throw APIError(status: 0, code: "invalid_next_page", detail: "The next staff page is unavailable. Refresh and try again.") + } + parts.queryItems = query.filter { $0.name == "page" } + try validate(parts.string, path: "/api/v1/admin/makerspace/\(makerspaceID)/inventory", after: page) + } + func containers(page: Int) async throws -> Components.Schemas.PaginatedBoxList { + let result = try await checked { try await service.staffContainers(makerspaceID: makerspaceID, page: page) } + try validate(result.next, path: "/api/v1/admin/makerspace/\(makerspaceID)/containers", after: page); return result + } + func createDirect(body: Components.Schemas.DirectLoanIssue) async throws -> Components.Schemas.DirectLoan { try await checked { try await service.staffCreateDirectLoan(makerspaceID: makerspaceID, body: body) } } + func returnDirect(id: Int, body: Components.Schemas.DirectLoanReturn) async throws -> Components.Schemas.DirectLoan { try await checked { try await service.staffReturnDirectLoan(id: id, body: body) } } + func evidence(body: Components.Schemas.EvidenceUrlRequest) async throws -> Components.Schemas.EvidenceUrlResponse { try await checked { try await service.staffEvidence(makerspaceID: makerspaceID, body: body) } } + func kitPreview(body: Components.Schemas.KitPreviewInput) async throws -> Components.Schemas.KitPreview { try await checked { try await service.staffKitPreview(makerspaceID: makerspaceID, body: body) } } + func events(page: Int) async throws -> Components.Schemas.EventListWithDefaults { + let result = try await checked { try await service.staffEvents(makerspaceID: makerspaceID, page: page) } + try validate(result.next, path: "/api/v1/admin/makerspaces/\(makerspaceID)/events/", after: page); return result + } + func resolveCheckIn(eventID: Int, body: Components.Schemas.EventCheckInResolveRequest) async throws -> Components.Schemas.EventCheckInResolveResponse { try await checked { try await service.staffResolveCheckIn(eventID: eventID, body: body) } } + func markAttended(registrationID: Int, body: Components.Schemas.EventAttendanceMark) async throws -> Components.Schemas.EventRegistrationAdmin { try await checked { try await service.staffMarkAttended(registrationID: registrationID, body: body) } } + func spaces(page: Int) async throws -> Components.Schemas.BookableSpaceListResponse { + let result = try await checked { try await service.staffSpaces(makerspaceID: makerspaceID, page: page) } + try validate(result.next, path: "/api/v1/admin/makerspaces/\(makerspaceID)/spaces/", after: page); return result + } + func bookings(spaceID: Int, page: Int) async throws -> Components.Schemas.BookingListResponse { + let result = try await checked { try await service.staffBookings(spaceID: spaceID, page: page) } + try validate(result.next, path: "/api/v1/admin/spaces/\(spaceID)/bookings/", after: page); return result + } + func approveBooking(id: Int) async throws -> Components.Schemas.BookingAdmin { try await checked { try await service.staffApproveBooking(id: id) } } + func rejectBooking(id: Int) async throws -> Components.Schemas.BookingAdmin { try await checked { try await service.staffRejectBooking(id: id) } } +} diff --git a/SpaceWorks/StaffState.swift b/SpaceWorks/StaffState.swift new file mode 100644 index 0000000..71426ac --- /dev/null +++ b/SpaceWorks/StaffState.swift @@ -0,0 +1,173 @@ +import Foundation +import Observation +import SpaceWorksAPI + +/// View state belongs to one authenticated makerspace. Its owner invalidates it before switching scope. +@MainActor @Observable +final class StaffState { + enum Load: Equatable { case idle, loading, ready, failed(String) } + let repository: any StaffRepository + let makerspaceID: Int + let actions: Set + let features: Set + var queue = StaffQueue.pending + private(set) var requests: [Components.Schemas.AdminRequest] = [] + private(set) var directLoans: [Components.Schemas.DirectLoan] = [] + private(set) var events: [Components.Schemas.EventAdmin] = [] + private(set) var spaces: [Components.Schemas.BookableSpaceAdmin] = [] + private(set) var bookings: [Components.Schemas.BookingAdmin] = [] + private(set) var load = Load.idle + private(set) var directLoad = Load.idle + private(set) var eventsLoad = Load.idle + private(set) var spacesLoad = Load.idle + private(set) var bookingsLoad = Load.idle + private(set) var busy = false + private(set) var error: String? + private(set) var notice: String? + private(set) var retryAfter = Date.distantPast + private(set) var nextQueuePage: Int? + private(set) var nextDirectPage: Int? + private(set) var nextEventPage: Int? + private(set) var nextSpacePage: Int? + private(set) var nextBookingPage: Int? + private var queueGeneration = 0 + private var bookingsGeneration = 0 + private var selectedSpace: Int? + private var active = true + @ObservationIgnored private var cooldownTask: Task? + private var children: [() -> Void] = [] + init(repository: any StaffRepository, makerspaceID: Int, actions: Set, features: Set) { + self.repository = repository; self.makerspaceID = makerspaceID + self.actions = actions; self.features = features + } + var isDemo: Bool { repository.isDemo } + var isActive: Bool { active } + var canRequest: Bool { active && !busy && Date() >= retryAfter } + func can(_ action: String) -> Bool { active && actions.contains(action) } + var canIssue: Bool { can("issue_request") && can("assign_box") && can("upload_evidence") } + var canReturn: Bool { can("return_request") && can("upload_evidence") } + func registerChild(_ invalidate: @escaping () -> Void) { if active { children.append(invalidate) } else { invalidate() } } + func invalidate() { + active = false; queueGeneration += 1; bookingsGeneration += 1; cooldownTask?.cancel(); cooldownTask = nil + children.forEach { $0() }; children = [] + requests = []; directLoans = []; events = []; spaces = []; bookings = [] + error = nil; notice = nil; busy = false + } + func loadQueue(more: Bool = false) async { + guard active, Date() >= retryAfter, !more || (nextQueuePage != nil && load != .loading) else { return } + queueGeneration += 1; let generation = queueGeneration; let selected = queue + let page = more ? (nextQueuePage ?? 1) : 1 + load = .loading; if !more { requests = []; nextQueuePage = nil } + do { + let result = try await repository.requests(queue: selected, page: page) + guard active, generation == queueGeneration, selected == queue else { return } + nextQueuePage = try StaffPaging.next(result.next, after: page) + requests = StaffPaging.unique((more ? requests : []) + result.results, id: { $0.id }) + load = .ready + } catch { guard active, generation == queueGeneration else { return }; refuse(error); load = .failed(APIError.display(error)) } + } + func accept(_ row: Components.Schemas.AdminRequest, quantities: [Int: Int]) async { + guard can("accept_request"), row.makerspace_id == makerspaceID, + row.items.allSatisfy({ $0.requested_quantity >= 0 && (0...$0.requested_quantity).contains(quantities[$0.id] ?? 0) }), + quantities.values.contains(where: { $0 > 0 }) else { error = "Choose a valid quantity for at least one item."; return } + let body = Components.Schemas.AcceptRequest(accepted_quantities: row.items.map { .init(item_id: $0.id, quantity: quantities[$0.id] ?? 0) }) + await mutate("Request accepted.") { _ = try await self.repository.accept(id: row.id, body: body) } + } + func reject(_ row: Components.Schemas.AdminRequest, reason: String) async { + let value = reason.trimmingCharacters(in: .whitespacesAndNewlines) + guard can("reject_request"), row.makerspace_id == makerspaceID, !value.isEmpty else { error = "Add a reason before rejecting this request."; return } + await mutate("Request rejected.") { _ = try await self.repository.reject(id: row.id, body: .init(reason: value)) } + } + private func mutate(_ success: String, operation: () async throws -> Void) async { + guard canRequest else { return }; busy = true; error = nil; notice = nil + do { try await operation(); guard active else { return }; notice = success; busy = false; await loadQueue() } + catch { guard active else { return }; refuse(error); self.error = APIError.display(error); busy = false } + } + func loadDirect(more: Bool = false) async { + guard active, Date() >= retryAfter, directLoad != .loading, !more || nextDirectPage != nil else { return } + let page = more ? nextDirectPage! : 1; directLoad = .loading + do { + let result = try await repository.directLoans(page: page); guard active else { return } + nextDirectPage = try StaffPaging.next(result.next, after: page) + directLoans = StaffPaging.unique((more ? directLoans : []) + result.results, id: { $0.id }); directLoad = .ready + } catch { guard active else { return }; refuse(error); directLoad = .failed(APIError.display(error)) } + } + func loadEvents(more: Bool = false) async { + guard active, Date() >= retryAfter, eventsLoad != .loading, !more || nextEventPage != nil else { return } + let page = more ? nextEventPage! : 1; eventsLoad = .loading + do { + let result = try await repository.events(page: page); guard active else { return } + nextEventPage = try StaffPaging.next(result.next, after: page) + events = StaffPaging.unique((more ? events : []) + result.results, id: { $0.id }); eventsLoad = .ready + } catch { guard active else { return }; refuse(error); eventsLoad = .failed(APIError.display(error)) } + } + func loadSpaces(more: Bool = false) async { + guard active, Date() >= retryAfter, spacesLoad != .loading, !more || nextSpacePage != nil else { return } + let page = more ? nextSpacePage! : 1; spacesLoad = .loading + do { + let result = try await repository.spaces(page: page); guard active else { return } + nextSpacePage = try StaffPaging.next(result.next, after: page) + spaces = StaffPaging.unique((more ? spaces : []) + result.results, id: { $0.id }); spacesLoad = .ready + } catch { guard active else { return }; refuse(error); spacesLoad = .failed(APIError.display(error)) } + } + func loadBookings(spaceID: Int, more: Bool = false) async { + guard active, Date() >= retryAfter, !more || (selectedSpace == spaceID && nextBookingPage != nil && bookingsLoad != .loading) else { return } + bookingsGeneration += 1; let generation = bookingsGeneration + let page = more ? (nextBookingPage ?? 1) : 1; selectedSpace = spaceID + bookingsLoad = .loading; if !more { bookings = []; nextBookingPage = nil } + do { + let result = try await repository.bookings(spaceID: spaceID, page: page) + guard active, generation == bookingsGeneration else { return } + nextBookingPage = try StaffPaging.next(result.next, after: page) + bookings = StaffPaging.unique((more ? bookings : []) + result.results, id: { $0.id }); bookingsLoad = .ready + } catch { guard active, generation == bookingsGeneration else { return }; refuse(error); bookingsLoad = .failed(APIError.display(error)) } + } + func decideBooking(_ row: Components.Schemas.BookingAdmin, approve: Bool) async { + guard can("manage_bookings"), canRequest, row.status.value1.rawValue == "pending", selectedSpace == row.space_id else { return } + busy = true; error = nil; notice = nil + let scopeGeneration = bookingsGeneration + do { + let updated: Components.Schemas.BookingAdmin + if approve { updated = try await repository.approveBooking(id: row.id) } + else { updated = try await repository.rejectBooking(id: row.id) } + busy = false + guard active, scopeGeneration == bookingsGeneration, selectedSpace == row.space_id else { return } + bookings = bookings.map { $0.id == updated.id ? updated : $0 } + notice = approve ? "Booking approved." : "Booking rejected."; busy = false + } catch { + guard active else { return }; refuse(error); self.error = APIError.display(error); busy = false + if active, scopeGeneration == bookingsGeneration, selectedSpace == row.space_id { await loadBookings(spaceID: row.space_id) } + } + } + func refuse(_ error: Error) { + if let deadline = APIError.retryDeadline(error), deadline > retryAfter { + retryAfter = deadline; cooldownTask?.cancel() + cooldownTask = Task { [weak self] in + let delay = max(0, deadline.timeIntervalSinceNow) + do { try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) } + catch { return } + guard let self, self.active else { return }; self.retryAfter = .distantPast + } + } + if [401, 403].contains(APIError.status(error)) { + invalidate(); self.error = APIError.display(error) + notice = "Your access changed. Return to your makerspace before continuing." + } + } +} + +enum StaffPaging { + static func next(_ raw: String?, after page: Int) throws -> Int? { + guard let raw, !raw.isEmpty else { return nil } + guard let parts = URLComponents(string: raw), let items = parts.queryItems, + items.filter({ $0.name == "page" }).count == 1, + let value = items.first(where: { $0.name == "page" })?.value, + let next = Int(value), next > page else { + throw APIError(status: 0, code: "invalid_next_page", detail: "The next page is unavailable. Refresh and try again.") + } + return next + } + static func unique(_ rows: [T], id: (T) -> Int) -> [T] { + var seen = Set(); return rows.filter { seen.insert(id($0)).inserted } + } +} diff --git a/SpaceWorks/StaffViews.swift b/SpaceWorks/StaffViews.swift new file mode 100644 index 0000000..eed3b5f --- /dev/null +++ b/SpaceWorks/StaffViews.swift @@ -0,0 +1,249 @@ +import SwiftUI +import SpaceWorksAPI + +struct StaffView: View { + @Bindable var state: StaffState + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + Text("Staff tools").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text("Review, hand over, and welcome people into your makerspace.").foregroundStyle(Color.swMuted) + if state.isDemo { Label("Demo staff tools · sample data", systemImage: "info.circle").foregroundStyle(Color.swMuted) } + SWCard { + if ["accept_request", "reject_request", "issue_request", "return_request"].contains(where: state.can) { + NavigationLink { StaffRequestsView(state: state) } label: { StaffMenuRow(title: "Requests and loans", detail: "Review requests and guide equipment handovers", symbol: "shippingbox") }.accessibilityIdentifier("staff-requests") + } + if state.can("issue_direct_loan") || state.canReturn { + NavigationLink { StaffDirectLoansView(state: state) } label: { StaffMenuRow(title: "Direct handout", detail: "Choose a borrower, equipment, and evidence", symbol: "person.crop.rectangle") }.accessibilityIdentifier("staff-direct") + } + if state.can("manage_bookings") { + NavigationLink { StaffSpacesView(state: state) } label: { StaffMenuRow(title: "Booking approvals", detail: "Review space reservations", symbol: "calendar") }.accessibilityIdentifier("staff-bookings") + } + if state.can("manage_events") { + NavigationLink { StaffEventsView(state: state) } label: { StaffMenuRow(title: "Event check-in", detail: "Scan, review the person, then confirm attendance", symbol: "qrcode.viewfinder") }.accessibilityIdentifier("staff-events") + } + } + Text("Only the tools allowed by your selected makerspace appear here.").font(.subheadline).foregroundStyle(Color.swMuted) + }.padding(24).frame(maxWidth: 900).frame(maxWidth: .infinity) + }.background(Color.swBg).foregroundStyle(Color.swInk).tint(Color.swAccentInk).navigationTitle("Staff tools") + } +} + +private struct StaffMenuRow: View { + let title: String; let detail: String; let symbol: String + var body: some View { + HStack(alignment: .top, spacing: 16) { + Image(systemName: symbol).font(.title2).accessibilityHidden(true) + VStack(alignment: .leading, spacing: 8) { Text(title).font(.headline); Text(detail).font(.subheadline).foregroundStyle(Color.swMuted) } + Spacer(minLength: 0); Image(systemName: "chevron.right").accessibilityHidden(true) + }.frame(minHeight: 64).padding(.vertical, 12).foregroundStyle(Color.swInk) + } +} + +struct StaffRequestsView: View { + @Bindable var state: StaffState + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 24) { + Picker("Request queue", selection: $state.queue) { + Text("Pending review").tag(StaffQueue.pending); Text("Ready for issue").tag(StaffQueue.accepted); Text("Active loans").tag(StaffQueue.active) + }.pickerStyle(.menu).frame(minHeight: 48).accessibilityIdentifier("staff-request-queue") + StaffFeedback(load: state.load) { await state.loadQueue() } + if let error = state.error { SWError(message: error) } + if let notice = state.notice { Label(notice, systemImage: "checkmark.circle").accessibilityIdentifier("staff-request-notice") } + if state.load == .ready && state.requests.isEmpty { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } + ForEach(state.requests, id: \.id) { row in + NavigationLink { StaffRequestDetail(state: state, row: row) } label: { + SWCard { + Text(row.requester_display.isEmpty ? row.requester_username : row.requester_display).font(.headline).foregroundStyle(Color.swInk) + Text("Request #\(row.id) · \(row.status.replacingOccurrences(of: "_", with: " ").capitalized)").foregroundStyle(Color.swMuted) + ForEach(row.items, id: \.id) { item in Text("\(item.product_name) × \(state.queue == .pending ? item.requested_quantity : item.accepted_quantity)").foregroundStyle(Color.swInk) } + Label("Review details", systemImage: "chevron.right").font(.subheadline) + } + }.buttonStyle(.plain).accessibilityIdentifier("staff-request-\(row.id)") + } + if state.nextQueuePage != nil { Button("Load more requests") { Task { await state.loadQueue(more: true) } }.frame(minHeight: 48) } + }.padding(24).frame(maxWidth: 900).frame(maxWidth: .infinity) + }.background(Color.swBg).navigationTitle("Requests and loans").navigationBarTitleDisplayMode(.inline) + .task(id: state.queue) { await state.loadQueue() }.refreshable { await state.loadQueue() } + } +} + +private struct StaffRequestDetail: View { + @Bindable var state: StaffState + let row: Components.Schemas.AdminRequest + @State private var quantities: [Int: Int] = [:] + @State private var reason = "" + @State private var decision: String? + @Environment(\.dismiss) private var dismiss + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + Text(row.requester_display.isEmpty ? row.requester_username : row.requester_display).font(.largeTitle.bold()) + Text("Request #\(row.id) · \(row.status.replacingOccurrences(of: "_", with: " ").capitalized)").foregroundStyle(Color.swMuted) + ForEach(row.items, id: \.id) { item in + SWCard { + Text(item.product_name).font(.headline) + if !item.storage_location.isEmpty { Label(item.storage_location, systemImage: "mappin").foregroundStyle(Color.swMuted) } + Text("Requested: \(item.requested_quantity) · Accepted: \(item.accepted_quantity)") + if state.queue == .pending && state.can("accept_request") { + Stepper("Accept \(quantities[item.id] ?? item.requested_quantity)", value: Binding(get: { quantities[item.id] ?? item.requested_quantity }, set: { quantities[item.id] = $0 }), in: 0...max(0, item.requested_quantity)).frame(minHeight: 48).accessibilityLabel("Accepted quantity for \(item.product_name)").accessibilityValue("\(quantities[item.id] ?? item.requested_quantity)") + } + } + } + if let error = state.error { SWError(message: error) } + if state.queue == .pending { + if state.can("accept_request") { Button("Accept request") { decision = "accept" }.buttonStyle(SWPrimary()).disabled(!state.canRequest) } + if state.can("reject_request") { + TextField("Reason for rejection", text: $reason, axis: .vertical).lineLimit(3...6).modifier(StaffInput()).disabled(state.busy).accessibilityIdentifier("staff-rejection-reason") + Button("Reject request", role: .destructive) { decision = "reject" }.frame(minHeight: 48).disabled(!state.canRequest || reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } else if state.queue == .accepted && state.canIssue { + NavigationLink("Start guided issue") { StaffHandoverView(state: StaffHandoverState(parent: state, mode: .issue(row))) }.buttonStyle(SWPrimary()) + } else if state.queue == .active && state.canReturn { + NavigationLink("Start guided return") { StaffHandoverView(state: StaffHandoverState(parent: state, mode: .returnRequest(row))) }.buttonStyle(SWPrimary()) + } + }.padding(24).frame(maxWidth: 720).frame(maxWidth: .infinity) + }.background(Color.swBg).navigationTitle("Request details").navigationBarTitleDisplayMode(.inline) + .confirmationDialog(decision == "accept" ? "Accept this request?" : "Reject this request?", isPresented: Binding(get: { decision != nil }, set: { if !$0 { decision = nil } }), titleVisibility: .visible) { + if decision == "accept" { Button("Confirm acceptance") { Task { await state.accept(row, quantities: quantities); if state.error == nil { dismiss() } } } } + else { Button("Confirm rejection", role: .destructive) { Task { await state.reject(row, reason: reason); if state.error == nil { dismiss() } } } } + Button("Cancel", role: .cancel) { decision = nil } + } + .onAppear { if quantities.isEmpty { quantities = Dictionary(uniqueKeysWithValues: row.items.map { ($0.id, $0.requested_quantity) }) } } + } +} + +private struct StaffDirectLoansView: View { + @Bindable var state: StaffState + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 24) { + if state.can("issue_direct_loan") && state.can("upload_evidence") { + NavigationLink("Start direct handout") { StaffHandoverView(state: StaffHandoverState(parent: state, mode: .directIssue)) }.buttonStyle(SWPrimary()) + } + StaffFeedback(load: state.directLoad) { await state.loadDirect() } + ForEach(state.directLoans, id: \.id) { row in + SWCard { + Text(row.target_label).font(.headline); Text("Loan #\(row.id) · \(row.status.capitalized)").foregroundStyle(Color.swMuted) + if let due = row.due_at { Text("Due \(due.formatted(date: .abbreviated, time: .shortened))") } + if row.status == "active" && state.canReturn { + NavigationLink("Record return") { StaffHandoverView(state: StaffHandoverState(parent: state, mode: .directReturn(row))) }.frame(minHeight: 48) + } + } + } + if state.directLoad == .ready && state.directLoans.isEmpty { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } + if state.nextDirectPage != nil { Button("Load more loans") { Task { await state.loadDirect(more: true) } }.frame(minHeight: 48) } + }.padding(24).frame(maxWidth: 900).frame(maxWidth: .infinity) + }.background(Color.swBg).navigationTitle("Direct handout").navigationBarTitleDisplayMode(.inline).task { await state.loadDirect() }.refreshable { await state.loadDirect() } + } +} + +private struct StaffEventsView: View { + @Bindable var state: StaffState + var body: some View { + ScrollView { LazyVStack(alignment: .leading, spacing: 24) { + StaffFeedback(load: state.eventsLoad) { await state.loadEvents() } + ForEach(state.events, id: \.id) { event in + NavigationLink { StaffCheckInView(state: StaffCheckInState(parent: state, event: event)) } label: { + SWCard { Text(event.title).font(.headline).foregroundStyle(Color.swInk); Text(event.starts_at, format: .dateTime.day().month().hour().minute()).foregroundStyle(Color.swMuted); Label("Open check-in", systemImage: "qrcode.viewfinder") } + }.buttonStyle(.plain) + } + if state.eventsLoad == .ready && state.events.isEmpty { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } + if state.nextEventPage != nil { Button("Load more events") { Task { await state.loadEvents(more: true) } }.frame(minHeight: 48) } + }.padding(24).frame(maxWidth: 900).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle("Event check-in").navigationBarTitleDisplayMode(.inline).task { await state.loadEvents() }.refreshable { await state.loadEvents() } + } +} + +private struct StaffCheckInView: View { + @State var state: StaffCheckInState + @State private var scanner = false + @Environment(\.dismiss) private var dismiss + var body: some View { + ScrollView { VStack(alignment: .leading, spacing: 24) { + if !state.parent.isActive { + SWError(message: state.parent.error ?? "Your access changed. Return to your makerspace before continuing.") + Button("Close") { dismiss() }.buttonStyle(SWPrimary()) + } else { + Text(state.eventTitle).font(.largeTitle.bold()) + Text("Scan a check-in code. Review the person below before recording attendance.").foregroundStyle(Color.swMuted) + Button("Scan check-in code") { scanner = true }.buttonStyle(SWPrimary()).disabled(state.busy) + if state.busy { ProgressView("Checking registration…") } + if let error = state.error { SWError(message: error) } + if let confirmation = state.confirmation { Label(confirmation, systemImage: "checkmark.circle").font(.headline).accessibilityIdentifier("staff-attendance-recorded") } + if let person = state.person { + SWCard { + Text(person.name).font(.title2.bold()); Text(person.status.replacingOccurrences(of: "_", with: " ").capitalized) + Text("Payment: \((person.payment_status ?? "Unavailable").replacingOccurrences(of: "_", with: " "))").foregroundStyle(Color.swMuted) + if state.canConfirm { Button("Confirm attendance") { Task { await state.confirm() } }.buttonStyle(SWPrimary()).accessibilityIdentifier("staff-confirm-attendance") } + else { Text("This registration cannot be checked in. Scan another code or ask the event manager.").foregroundStyle(Color.swMuted) } + }.accessibilityIdentifier("staff-resolved-person") + } + } + }.padding(24).frame(maxWidth: 720).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle("Check-in").navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $scanner) { NativeQRScanner(title: "Check-in code") { value in scanner = false; Task { await state.resolve(value) } } } + } +} + +private struct StaffSpacesView: View { + @Bindable var state: StaffState + var body: some View { + ScrollView { LazyVStack(alignment: .leading, spacing: 24) { + StaffFeedback(load: state.spacesLoad) { await state.loadSpaces() } + ForEach(state.spaces, id: \.id) { space in + NavigationLink { StaffBookingsView(state: state, spaceID: space.id, name: space.name) } label: { SWCard { StaffMenuRow(title: space.name, detail: "Review reservations", symbol: "calendar") } }.buttonStyle(.plain) + } + if state.spacesLoad == .ready && state.spaces.isEmpty { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } + if state.nextSpacePage != nil { Button("Load more spaces") { Task { await state.loadSpaces(more: true) } }.frame(minHeight: 48) } + }.padding(24).frame(maxWidth: 900).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle("Booking approvals").navigationBarTitleDisplayMode(.inline).task { await state.loadSpaces() }.refreshable { await state.loadSpaces() } + } +} + +private struct StaffBookingsView: View { + @Bindable var state: StaffState + let spaceID: Int; let name: String + @State private var decision: (row: Components.Schemas.BookingAdmin, approve: Bool)? + var body: some View { + ScrollView { LazyVStack(alignment: .leading, spacing: 24) { + StaffFeedback(load: state.bookingsLoad) { await state.loadBookings(spaceID: spaceID) } + if let error = state.error { SWError(message: error) } + if let notice = state.notice { Text(notice).accessibilityIdentifier("staff-booking-notice") } + ForEach(state.bookings, id: \.id) { row in + SWCard { + Text(row.name).font(.headline); Text(row.starts_at, format: .dateTime.day().month().hour().minute()) + Text(row.status.value1.rawValue.replacingOccurrences(of: "_", with: " ").capitalized).foregroundStyle(Color.swMuted) + if !row.note.isEmpty { Text(row.note) } + if row.status.value1.rawValue == "pending" { + Button("Approve booking") { decision = (row, true) }.buttonStyle(SWPrimary()).disabled(!state.canRequest) + Button("Reject booking", role: .destructive) { decision = (row, false) }.frame(minHeight: 48).disabled(!state.canRequest) + } + } + } + if state.bookingsLoad == .ready && state.bookings.isEmpty { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } + if state.nextBookingPage != nil { Button("Load more bookings") { Task { await state.loadBookings(spaceID: spaceID, more: true) } }.frame(minHeight: 48) } + }.padding(24).frame(maxWidth: 900).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle(name).navigationBarTitleDisplayMode(.inline).task { await state.loadBookings(spaceID: spaceID) }.refreshable { await state.loadBookings(spaceID: spaceID) } + .confirmationDialog(decision?.approve == true ? "Approve this booking?" : "Reject this booking?", isPresented: Binding(get: { decision != nil }, set: { if !$0 { decision = nil } }), titleVisibility: .visible) { + if let decision { Button(decision.approve ? "Confirm approval" : "Confirm rejection") { Task { await state.decideBooking(decision.row, approve: decision.approve); self.decision = nil } } } + Button("Cancel", role: .cancel) { decision = nil } + } + } +} + +struct StaffFeedback: View { + let load: StaffState.Load + let retry: () async -> Void + var body: some View { + switch load { + case .loading: ProgressView("Loading…") + case .failed(let message): SWError(message: message); Button("Try again") { Task { await retry() } }.frame(minHeight: 48) + default: EmptyView() + } + } +} + +struct StaffInput: ViewModifier { + func body(content: Content) -> some View { + content.padding(16).frame(minHeight: 48).background(Color.swPanel, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)).foregroundStyle(Color.swInk) + } +} diff --git a/SpaceWorksTests/StaffTests.swift b/SpaceWorksTests/StaffTests.swift new file mode 100644 index 0000000..8a54795 --- /dev/null +++ b/SpaceWorksTests/StaffTests.swift @@ -0,0 +1,324 @@ +import Foundation +import XCTest +import SpaceWorksAPI +@testable import SpaceWorks + +private enum StaffFixture { + static let actions: Set = ["accept_request", "reject_request", "assign_box", "issue_request", "return_request", "upload_evidence", "issue_direct_loan", "manage_events", "manage_bookings"] + static func decode(_ value: String, changes: [String: Any] = [:]) throws -> T { + var object = try JSONSerialization.jsonObject(with: Data(value.utf8)) as! [String: Any] + object.merge(changes) { _, new in new } + let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(T.self, from: JSONSerialization.data(withJSONObject: object)) + } + static func page(_ json: String, extra: [String: Any] = [:]) throws -> String { + var object: [String: Any] = ["count": 1, "next": NSNull(), "previous": NSNull(), "results": [try JSONSerialization.jsonObject(with: Data(json.utf8))]] + object.merge(extra) { _, new in new } + return String(decoding: try JSONSerialization.data(withJSONObject: object), as: UTF8.self) + } +} + +#if DEBUG +@MainActor final class StaffTests: XCTestCase { + private func state(_ repository: DemoStaffRepository = DemoStaffRepository(), kits: Bool = false, actions: Set = StaffFixture.actions) -> StaffState { + StaffState(repository: repository, makerspaceID: 7, actions: actions, features: kits ? ["loans.kit_checklist"] : []) + } + private var photo: Data { Data([0xff, 0xd8, 0xff, 0xe0, 0, 1, 2]) } + private func issue(_ parent: StaffState) async throws -> StaffHandoverState { + parent.queue = .accepted; await parent.loadQueue() + let row = try XCTUnwrap(parent.requests.first) + let handover = StaffHandoverState(parent: parent, mode: .issue(row)) + await handover.scan("DEMO-BOX", box: true); handover.borrowerChecked = true + handover.setPhoto(photo); await handover.uploadPhoto(); await handover.prepareKits() + return handover + } + func testDemoStaffRowsMatchTheirActualSelectedMakerspace() async throws { + let repository = DemoStaffRepository(makerspaceID: 8) + let parent = StaffState(repository: repository, makerspaceID: 8, actions: StaffFixture.actions, features: []) + await parent.loadQueue(); let row = try XCTUnwrap(parent.requests.first) + XCTAssertEqual(row.makerspace_id, 8) + await parent.accept(row, quantities: [61: 2]); XCTAssertNil(parent.error) + parent.queue = .accepted; await parent.loadQueue() + XCTAssertTrue(parent.requests.allSatisfy { $0.makerspace_id == 8 }) + } + func testAcceptMovesActualRequestIntoAcceptedQueue() async throws { + let parent = state(); await parent.loadQueue(); let row = try XCTUnwrap(parent.requests.first) + await parent.accept(row, quantities: [61: 2]); XCTAssertNil(parent.error); XCTAssertTrue(parent.requests.isEmpty) + parent.queue = .accepted; await parent.loadQueue(); XCTAssertTrue(parent.requests.contains { $0.id == row.id }) + } + func testRejectionNeedsReasonAndSelectedCapability() async throws { + let parent = state(); await parent.loadQueue(); let row = try XCTUnwrap(parent.requests.first) + await parent.reject(row, reason: " "); XCTAssertNotNil(parent.error); XCTAssertEqual(parent.requests.count, 1) + let restricted = state(actions: []); await restricted.loadQueue(); await restricted.reject(row, reason: "Unavailable") + XCTAssertNotNil(restricted.error); XCTAssertEqual(restricted.requests.count, 1) + } + func testIssueRequiresPhotoAndExplicitBorrowerCheck() async throws { + let parent = state(); parent.queue = .accepted; await parent.loadQueue() + let handover = StaffHandoverState(parent: parent, mode: .issue(try XCTUnwrap(parent.requests.first))) + await handover.scan("DEMO-BOX", box: true); XCTAssertFalse(handover.ready) + handover.setPhoto(photo); await handover.uploadPhoto(); XCTAssertFalse(handover.ready) + handover.borrowerChecked = true; XCTAssertTrue(handover.ready) + await handover.confirm(); XCTAssertNotNil(handover.receipt); XCTAssertTrue(parent.requests.isEmpty) + } + func testKitCountsStartEmptyAndRequireAnExplicitCountIncludingZero() async throws { + let parent = state(kits: true); let handover = try await issue(parent) + XCTAssertTrue(handover.kitsReady); XCTAssertTrue(handover.kitCounts.isEmpty); XCTAssertFalse(handover.ready) + let key = try XCTUnwrap(handover.kits.first?.key); handover.kitCounts[key] = 0; XCTAssertTrue(handover.ready) + handover.kitCounts[key] = 3; XCTAssertFalse(handover.ready) + } + func testRetakingPhotoClearsPreviouslyAuthorizedEvidence() async throws { + let parent = state(); let handover = try await issue(parent) + XCTAssertNotNil(handover.evidenceID); handover.setPhoto(photo); XCTAssertNil(handover.evidenceID); XCTAssertFalse(handover.ready) + } + func testFailedIssuePreservesPartialAssignmentAndRequiresFreshRequest() async throws { + let parent = state(DemoStaffRepository(scenario: .issueConflict)); let handover = try await issue(parent) + XCTAssertTrue(handover.ready); await handover.confirm() + XCTAssertTrue(handover.assignmentRecorded); XCTAssertTrue(handover.needsReload); XCTAssertNil(handover.receipt) + XCTAssertTrue(handover.notice?.contains("Box assignment was recorded") == true); XCTAssertFalse(handover.ready) + } + func testReturnStartsUnconfirmedAndRequiresRemark() async throws { + let parent = state(); parent.queue = .active; await parent.loadQueue() + let handover = StaffHandoverState(parent: parent, mode: .returnRequest(try XCTUnwrap(parent.requests.first))) + XCTAssertEqual(handover.returns.first?.returned, 0); XCTAssertFalse(handover.itemsReady) + handover.returns[0].returned = 2; await handover.scan("DEMO-BOX", box: true) + handover.setPhoto(photo); await handover.uploadPhoto(); XCTAssertFalse(handover.ready) + handover.remark = "Condition checked; both returned."; XCTAssertTrue(handover.ready) + handover.returns[0].damaged = 1; XCTAssertFalse(handover.ready) + } + func testIndividualOutcomeRequiresInspectionAndProducesMatchingCounts() { + var item = StaffReturnDraft(id: 1, label: "Sample", remaining: 2, assets: [(1, "A"), (2, "B")], returned: 0) + XCTAssertTrue(item.outcomes.isEmpty); item.setOutcome(assetID: 1, value: "damaged") + XCTAssertEqual(item.body.returned, 0); XCTAssertEqual(item.body.damaged, 1) + XCTAssertEqual(item.body.assets?.first?.asset_id, 1); item.setOutcome(assetID: 1, value: "pending") + XCTAssertEqual(item.body.damaged, 0); XCTAssertEqual(item.body.assets?.count, 0) + } + func testBooleanIssuedAssetMetadataCannotBecomePhysicalAssetOne() throws { + var object = try JSONSerialization.jsonObject(with: Data(DebugStaffFixtures.request.utf8)) as! [String: Any] + var items = object["items"] as! [[String: Any]] + items[0]["requires_asset_qr"] = true + items[0]["issued_assets"] = [["asset_id": true, "asset_tag": "Invalid"]] + object["items"] = items + let row: Components.Schemas.AdminRequest = try StaffFixture.decode(DebugStaffFixtures.request, changes: object) + let handover = StaffHandoverState(parent: state(), mode: .returnRequest(row)) + XCTAssertFalse(handover.returns[0].metadataValid); XCTAssertFalse(handover.ready) + XCTAssertTrue(handover.returns[0].assets.isEmpty) + XCTAssertNil(StaffMetadata.positiveID(true)); XCTAssertNil(StaffMetadata.positiveID("1")) + XCTAssertNil(StaffMetadata.positiveID(0)); XCTAssertEqual(StaffMetadata.positiveID(1), 1) + } + func testMalformedReturnCountsCannotUnderflowOrBecomeAnActionableEmptyReturn() throws { + XCTAssertNil(StaffMetadata.remaining(issued: 0, returned: Int.max, damaged: Int.max, missing: 1)) + XCTAssertNil(StaffMetadata.remaining(issued: -1, returned: 0, damaged: 0, missing: 0)) + XCTAssertEqual(StaffMetadata.remaining(issued: Int.max, returned: Int.max, damaged: 0, missing: 0), 0) + var object = try JSONSerialization.jsonObject(with: Data(DebugStaffFixtures.request.utf8)) as! [String: Any] + var items = object["items"] as! [[String: Any]] + items[0]["issued_quantity"] = 0; items[0]["returned_quantity"] = Int.max; items[0]["damaged_quantity"] = Int.max + object["items"] = items + let row: Components.Schemas.AdminRequest = try StaffFixture.decode(DebugStaffFixtures.request, changes: object) + let handover = StaffHandoverState(parent: state(), mode: .returnRequest(row)) + XCTAssertFalse(handover.returns[0].metadataValid); XCTAssertFalse(handover.ready) + } + func testLateCameraCallbackCannotRepopulateInvalidatedHandover() { + let handover = StaffHandoverState(parent: state(), mode: .directIssue) + handover.setPhoto(photo); XCTAssertNotNil(handover.photo) + handover.invalidate(); handover.setPhoto(photo) + XCTAssertNil(handover.photo); XCTAssertNil(handover.evidenceID); XCTAssertFalse(handover.ready) + } + func testLateCameraCallbackDuringUploadPreservesOriginalPhotoAndSettlesBusy() async throws { + let gate = StaffReadGate() + let parent = state(DemoStaffRepository(beforeEvidence: { await gate.suspend() })) + parent.queue = .accepted; await parent.loadQueue() + let handover = StaffHandoverState(parent: parent, mode: .issue(try XCTUnwrap(parent.requests.first))) + let original = photo + let replacement = Data([0xff, 0xd8, 0xff, 0xe0, 8, 7, 6, 5]) + handover.setPhoto(original) + let pending = Task { await handover.uploadPhoto() } + await gate.waitUntilStarted() + + XCTAssertTrue(handover.busy) + XCTAssertNil(handover.evidenceID) + handover.setPhoto(replacement) + XCTAssertEqual(handover.photo, original) + XCTAssertTrue(handover.busy) + + await gate.release(); await pending.value + XCTAssertFalse(handover.busy) + XCTAssertEqual(handover.photo, original) + XCTAssertEqual(handover.evidenceID, 1301) + XCTAssertNil(handover.error) + + // A later deliberate retake is allowed and invalidates the old evidence association. + handover.setPhoto(replacement) + XCTAssertEqual(handover.photo, replacement) + XCTAssertNil(handover.evidenceID) + } + func testDirectHandoutUsesUserIDAndRejectsMembershipIDAsBorrower() async throws { + let parent = state(); let handover = StaffHandoverState(parent: parent, mode: .directIssue) + await handover.loadBorrowers(); await handover.loadProducts(); handover.bulkQuantities[901] = 1 + handover.setPhoto(photo); await handover.uploadPhoto(); handover.borrowerID = 81 + XCTAssertFalse(handover.ready); handover.borrowerID = 51; XCTAssertTrue(handover.ready) + } + func testChangingReturnCountsInvalidatesKitPreview() async throws { + let parent = state(kits: true); parent.queue = .active; await parent.loadQueue() + let handover = StaffHandoverState(parent: parent, mode: .returnRequest(try XCTUnwrap(parent.requests.first))) + handover.returns[0].returned = 1; await handover.prepareKits(); XCTAssertTrue(handover.kitsReady) + handover.returns[0].returned = 2; XCTAssertFalse(handover.kitsReady); XCTAssertTrue(handover.kitCounts.isEmpty) + } + func testCheckInResolveRequiresSeparateConfirmation() async throws { + let parent = state(); await parent.loadEvents(); let event = try XCTUnwrap(parent.events.first) + let checkin = StaffCheckInState(parent: parent, event: event); await checkin.resolve("DEMO-EVENT") + XCTAssertTrue(checkin.canConfirm); XCTAssertEqual(checkin.person?.status, "registered"); XCTAssertNil(checkin.confirmation) + await checkin.resolve("DEMO-EVENT"); XCTAssertEqual(checkin.person?.status, "registered") + await checkin.confirm(); XCTAssertNotNil(checkin.confirmation); XCTAssertNil(checkin.person) + await checkin.resolve("DEMO-EVENT"); XCTAssertEqual(checkin.person?.status, "attended"); XCTAssertFalse(checkin.canConfirm) + } + func testWaitlistedRegistrationCannotBeConfirmed() async throws { + let parent = state(DemoStaffRepository(scenario: .waitlisted)); await parent.loadEvents() + let checkin = StaffCheckInState(parent: parent, event: try XCTUnwrap(parent.events.first)); await checkin.resolve("DEMO-EVENT") + XCTAssertEqual(checkin.person?.status, "waitlisted"); XCTAssertFalse(checkin.canConfirm) + await checkin.confirm(); XCTAssertNil(checkin.confirmation) + } + func testCheckInConflictClearsPersonAndRequiresRescanWithoutClaimingAttendance() async throws { + let parent = state(DemoStaffRepository(scenario: .attendanceConflict)); await parent.loadEvents() + let checkin = StaffCheckInState(parent: parent, event: try XCTUnwrap(parent.events.first)); await checkin.resolve("DEMO-EVENT"); await checkin.confirm() + XCTAssertNil(checkin.person); XCTAssertNil(checkin.confirmation); XCTAssertFalse(checkin.canConfirm) + XCTAssertEqual(checkin.error, "This registration changed. Scan the code again to check its latest status.") + } + func testScopeInvalidationClearsPhotoScansPersonAndRows() async throws { + let parent = state(); let handover = try await issue(parent); await parent.loadEvents() + let checkin = StaffCheckInState(parent: parent, event: try XCTUnwrap(parent.events.first)); await checkin.resolve("DEMO-EVENT") + parent.invalidate(); XCTAssertNil(handover.photo); XCTAssertNil(handover.evidenceID); XCTAssertTrue(handover.scans.isEmpty) + XCTAssertNil(checkin.person); XCTAssertTrue(parent.requests.isEmpty); XCTAssertTrue(parent.events.isEmpty) + await handover.confirm(); XCTAssertNil(handover.receipt); await checkin.confirm(); XCTAssertNil(checkin.confirmation) + } + func testBookingDecisionUsesReturnedServerState() async throws { + let parent = state(); await parent.loadBookings(spaceID: 901); let row = try XCTUnwrap(parent.bookings.first) + await parent.decideBooking(row, approve: true); XCTAssertEqual(parent.bookings.first?.status.value1.rawValue, "confirmed") + } + func testBookingConflictReconcilesAuthoritativeStateBeforeAnotherDecision() async throws { + let parent = state(DemoStaffRepository(scenario: .bookingConflict)); await parent.loadBookings(spaceID: 901) + await parent.decideBooking(try XCTUnwrap(parent.bookings.first), approve: true) + XCTAssertNotNil(parent.error); XCTAssertEqual(parent.bookings.first?.status.value1.rawValue, "confirmed"); XCTAssertFalse(parent.busy) + } + func testAuthorityRefusalPreventsAnotherInFlightResourceFromPublishingPrivateRows() async { + let gate = StaffReadGate() + let parent = state(DemoStaffRepository(beforeEvents: { await gate.suspend() })) + let pending = Task { await parent.loadEvents() } + await gate.waitUntilStarted() + parent.refuse(APIError(status: 403, code: "forbidden", detail: "Your staff access changed.")) + await gate.release(); await pending.value + XCTAssertFalse(parent.isActive); XCTAssertTrue(parent.events.isEmpty); XCTAssertFalse(parent.can("manage_events")) + } + func testActualNextPageAndDuplicatePageRefusal() throws { + XCTAssertEqual(try StaffPaging.next("?page=3", after: 1), 3) + for raw in ["?page=1", "?page=2&page=3", "?page=-2", "?page=x"] { XCTAssertThrowsError(try StaffPaging.next(raw, after: 1)) } + } +} + +private actor StaffReadGate { + private var started = false + private var ready: CheckedContinuation? + private var resumed: CheckedContinuation? + func suspend() async { + started = true; ready?.resume(); ready = nil + await withCheckedContinuation { resumed = $0 } + } + func waitUntilStarted() async { if !started { await withCheckedContinuation { ready = $0 } } } + func release() { resumed?.resume(); resumed = nil } +} +#endif + +final class StaffOperationTests: XCTestCase { + func testRetainedStaffRepositoryCannotFirstReadOrIssueUnderReplacementAccount() async throws { + let fixture = try await OwnedRepositoryFixture.make(); let probe = fixture.probe + let repository = LiveStaffRepository(service: fixture.service, expectedCredentials: fixture.credentials, + makerspaceID: 7, origin: fixture.origin, onUnauthorized: { _ in probe.notified() }, onForbidden: { probe.notified() }) + try await fixture.replaceAccount() + let operations: [@Sendable () async throws -> Void] = [ + { _ = try await repository.requests(queue: .pending, page: 1) }, + { _ = try await repository.assignBox(id: 601, body: .init(box_code: "retired-code")) }, + { _ = try await repository.approveBooking(id: 1001) } + ] + for operation in operations { + do { try await operation(); XCTFail("Retired staff authority sent a request") } + catch { XCTAssertTrue(APIError.isCancellation(error)) } + } + XCTAssertEqual(probe.counts.requests, 0); XCTAssertEqual(probe.counts.callbacks, 0) + } + override func tearDown() { StubProtocol.lock.lock(); StubProtocol.handler = nil; StubProtocol.lock.unlock() } + private func repository(handler: @escaping (URLRequest) throws -> (Int, String)) async throws -> LiveStaffRepository { + StubProtocol.lock.lock(); StubProtocol.handler = { request in + XCTAssertEqual(request.url?.host, "spaceworks.example") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer staff-access") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Makerspace-Id"), "7") + for key in ["Cookie", "Origin", "Referer"] { XCTAssertNil(request.value(forHTTPHeaderField: key)) } + let (status, json) = try handler(request); return (status, ["Content-Type": "application/json"], Data(json.utf8)) + }; StubProtocol.lock.unlock() + let config = DeviceSession.configuration(); config.protocolClasses = [StubProtocol.self] + let origin = try ServerOrigin("https://spaceworks.example") + let service = SpaceWorksService(origin: origin, session: DeviceSession.make(configuration: config), initialTokens: .init(access: "staff-access", refresh: "staff-refresh")) + try await service.makerspace.select(7, activeMemberships: [7, 9]) + return LiveStaffRepository(service: service, expectedCredentials: await service.refresh.snapshot(), makerspaceID: 7, origin: origin) + } + func testAll24GeneratedStaffOperationsRetainScopeExactRoutesAndTypedBodies() async throws { + var calls = Set() + let repo = try await repository { request in + let path = request.url!.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let key = "\(request.httpMethod!) \(path)"; calls.insert(key) + if path.contains("pending-requests") || path.contains("accepted-requests") || path.contains("active-loans") { return (200, try StaffFixture.page(DebugStaffFixtures.request)) } + if path.contains("admin/requests/") { return (200, DebugStaffFixtures.request) } + if path.hasSuffix("qr/resolve") { return (200, DebugStaffFixtures.qr) } + if path.hasSuffix("direct-loan-members") { return (200, DebugStaffFixtures.members) } + if path.hasSuffix("inventory") { return (200, DebugStaffFixtures.inventory) } + if path.hasSuffix("containers") { return (200, DebugStaffFixtures.containers) } + if path.hasSuffix("direct-loans") { return request.httpMethod == "POST" ? (201, DebugStaffFixtures.loan) : (200, try StaffFixture.page(DebugStaffFixtures.loan)) } + if path.contains("direct-loans/") { return (200, DebugStaffFixtures.loan) } + if path.hasSuffix("evidence-url") { + let body = try JSONSerialization.jsonObject(with: requestBody(request)) as! [String: Any] + XCTAssertEqual(body["evidence_type"] as? String, "issue"); XCTAssertEqual(body["size_bytes"] as? Int, 3) + return (201, #"{"evidence_id":1301,"upload_url":"https://storage.example.invalid/upload","object_key":"sample","fields":{"key":"sample"}}"#) + } + if path.hasSuffix("kit-checklist-preview") { return (200, DebugStaffFixtures.kits) } + if path.hasSuffix("check-in/resolve") { + let body = try JSONSerialization.jsonObject(with: requestBody(request)) as! [String: Any] + XCTAssertEqual(body["checkin_token"] as? String, "opaque-checkin"); XCTAssertNil(body["payload"]) + return (200, DebugStaffFixtures.checkin) + } + if path.hasSuffix("mark-attended") { + let body = try JSONSerialization.jsonObject(with: requestBody(request)) as! [String: Any]; XCTAssertEqual(body["source"] as? String, "qr") + return (200, DebugStaffFixtures.registration) + } + if path.hasSuffix("events") { return (200, try StaffFixture.page(DebugStaffFixtures.event, extra: ["default_registration_audience": "members"])) } + if path.hasSuffix("spaces") { return (200, try StaffFixture.page(DebugStaffFixtures.space)) } + if path.hasSuffix("bookings") { return (200, try StaffFixture.page(DebugStaffFixtures.booking)) } + if path.hasSuffix("approve") || path.hasSuffix("reject") { XCTAssertTrue(requestBody(request).isEmpty); return (200, DebugStaffFixtures.booking) } + XCTFail("Unexpected generated staff route: \(key)"); return (500, "{}") + } + for queue in StaffQueue.allCases { _ = try await repo.requests(queue: queue, page: 1) } + _ = try await repo.accept(id: 601, body: .init(accepted_quantities: [.init(item_id: 61, quantity: 2)])) + _ = try await repo.reject(id: 601, body: .init(reason: "Equipment unavailable")) + _ = try await repo.assignBox(id: 601, body: .init(box_code: "opaque-box")) + _ = try await repo.issue(id: 601, body: .init(evidence_id: 1301)) + _ = try await repo.returnRequest(id: 601, body: .init(evidence_id: 1301, box_code: "opaque-box", remark: "Checked", resolutions: [.init(item_id: 61, returned: 1)])) + _ = try await repo.resolveQR(body: .init(payload: "opaque-code")) + _ = try await repo.directLoans(page: 1); _ = try await repo.directMembers(page: 1); _ = try await repo.inventory(page: 1, search: "tool"); _ = try await repo.containers(page: 1) + _ = try await repo.createDirect(body: .init(borrower_id: 51, evidence_id: 1301, items: [.init(product_id: 901, quantity: 1)])) + _ = try await repo.returnDirect(id: 701, body: .init(evidence_id: 1301, notes: "Checked")) + _ = try await repo.evidence(body: .init(evidence_type: .issue, content_type: "image/jpeg", size_bytes: 3)) + _ = try await repo.kitPreview(body: .init(request_id: 601)); _ = try await repo.events(page: 1) + _ = try await repo.resolveCheckIn(eventID: 801, body: .init(checkin_token: "opaque-checkin")) + _ = try await repo.markAttended(registrationID: 1101, body: .init(source: .init(value1: .qr))) + _ = try await repo.spaces(page: 1); _ = try await repo.bookings(spaceID: 901, page: 1) + _ = try await repo.approveBooking(id: 1001); _ = try await repo.rejectBooking(id: 1001) + XCTAssertEqual(calls.count, 24) + } + func testRetainedStaffRepositoryRefusesScopeDriftBeforeSendingAnIDOnlyMutation() async throws { + var calls = 0 + let repo = try await repository { _ in calls += 1; return (200, DebugStaffFixtures.booking) } + try await repo.service.makerspace.select(9, activeMemberships: [7, 9]) + do { _ = try await repo.approveBooking(id: 1001); XCTFail("Old scope sent a mutation") } catch { XCTAssertEqual(calls, 0) } + } + func testNextPageCannotChangeOriginResourceOrRegress() async throws { + let repo = try await repository { _ in (200, #"{"count":1,"next":"https://evil.example/?page=2","previous":null,"results":[]}"#) } + do { _ = try await repo.requests(queue: .pending, page: 1); XCTFail("Foreign page accepted") } catch { XCTAssertNotNil(error) } + } +} diff --git a/SpaceWorksUITests/PaymentsFlowTests.swift b/SpaceWorksUITests/PaymentsFlowTests.swift index 3fa9782..570f843 100644 --- a/SpaceWorksUITests/PaymentsFlowTests.swift +++ b/SpaceWorksUITests/PaymentsFlowTests.swift @@ -1,4 +1,5 @@ import XCTest +import Vision final class PaymentsFlowTests: XCTestCase { override func setUp() { continueAfterFailure = false; XCUIDevice.shared.orientation = .portrait } @@ -35,6 +36,32 @@ final class PaymentsFlowTests: XCTestCase { attach("Native demo checkout outcome") action.tap() } + private func waitForRenderedReceipt(in app: XCUIApplication) { + var lastRecognizedText = "" + let visibleReceipt = NSPredicate { _, _ in + guard let image = XCUIScreen.main.screenshot().image.cgImage else { return false } + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + request.recognitionLanguages = ["en-US"] + request.usesLanguageCorrection = false + do { try VNImageRequestHandler(cgImage: image, options: [:]).perform([request]) } + catch { return false } + lastRecognizedText = (request.results ?? []).compactMap { $0.topCandidates(1).first?.string }.joined(separator: " ") + let text = lastRecognizedText.uppercased() + // Both phrases occur in the actual PDF page, never in the backing document list. + return text.contains("DEMO RECEIPT") && text.contains("NO MONEY MOVED") + } + let ready = XCTNSPredicateExpectation(predicate: visibleReceipt, object: nil) + let result = XCTWaiter.wait(for: [ready], timeout: 20) + if result != .completed { + attach("Native receipt rendering not ready") + let hierarchy = XCTAttachment(string: app.debugDescription) + hierarchy.name = "Native receipt accessibility hierarchy"; hierarchy.lifetime = .keepAlways; add(hierarchy) + let recognized = XCTAttachment(string: lastRecognizedText) + recognized.name = "Native receipt visible text"; recognized.lifetime = .keepAlways; add(recognized) + } + XCTAssertEqual(result, .completed, "The actual native PDF page must render its sample receipt before capture.") + } private func attach(_ name: String) { let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); attachment.name = name; attachment.lifetime = .keepAlways; add(attachment) } @@ -47,7 +74,9 @@ final class PaymentsFlowTests: XCTestCase { tap(app.buttons["payment-demo-delayed-confirmation"], in: app) XCTAssertTrue(app.descendants(matching: .any)["payment-confirmed"].firstMatch.waitForExistence(timeout: 10)) tap(app.buttons["Receipts and credit notes"], in: app); tap(app.buttons["document-901"], in: app) - XCTAssertTrue(app.navigationBars["Payment document"].waitForExistence(timeout: 10)); attach("Payment native PDF receipt") + XCTAssertTrue(app.navigationBars["Payment document"].waitForExistence(timeout: 10)) + waitForRenderedReceipt(in: app) + attach("Payment native PDF receipt") tap(app.buttons["Done"], in: app) XCTAssertTrue(app.buttons["document-901"].waitForExistence(timeout: 5)) } diff --git a/SpaceWorksUITests/StaffFlowTests.swift b/SpaceWorksUITests/StaffFlowTests.swift new file mode 100644 index 0000000..e38d9e3 --- /dev/null +++ b/SpaceWorksUITests/StaffFlowTests.swift @@ -0,0 +1,60 @@ +import XCTest + +final class StaffFlowTests: XCTestCase { + override func setUp() { continueAfterFailure = false; XCUIDevice.shared.orientation = .portrait } + private func tap(_ element: XCUIElement, in app: XCUIApplication) { NativeUIActions.tap(element, in: app) } + private func staff(extra: [String] = []) -> XCUIApplication { + let app = XCUIApplication(); app.launchArguments = ["--ui-testing"] + extra; app.launch() + tap(app.buttons["explore-demo"], in: app) + tap(app.segmentedControls["demo-role"].buttons["Staff"], in: app) + tap(app.buttons["Continue as staff"], in: app) + let username = app.textFields["demo-username"] + tap(username, in: app); username.typeText("staff\n") + let password = app.secureTextFields["demo-password"]; XCTAssertTrue(password.waitForExistence(timeout: 5)); password.typeText("sample\n") + tap(app.buttons["demo-sign-in"], in: app); tap(app.buttons["demo-space-0"], in: app) + if app.tabBars.buttons["Profile"].exists { tap(app.tabBars.buttons["Profile"], in: app) } + else { + let profile = app.descendants(matching: .any)["nav-Profile"].firstMatch + if !profile.isHittable { + let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'sidebar'")).firstMatch + if sidebar.exists { sidebar.tap() } + } + tap(profile, in: app) + } + tap(app.buttons["staff-tools"], in: app) + return app + } + private func scan(_ payload: String, app: XCUIApplication) { + let field = app.textFields["scanner-manual-code"] + tap(field, in: app); field.typeText(payload + "\n") + } + private func attach(_ name: String) { + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); attachment.name = name; attachment.lifetime = .keepAlways; add(attachment) + } + func testGuidedIssueRequiresBorrowerEvidenceAndFinalConfirmation() { + let app = staff() + tap(app.buttons["staff-requests"], in: app); tap(app.buttons["staff-request-queue"], in: app) + tap(app.buttons["Ready for issue"], in: app); tap(app.buttons["staff-request-602"], in: app) + tap(app.buttons["Start guided issue"], in: app); tap(app.buttons["Scan handover code"], in: app) + scan("DEMO-BOX", app: app); tap(app.buttons["staff-handover-next"], in: app) + XCTAssertFalse(app.buttons["staff-handover-next"].isEnabled) + tap(app.switches["staff-borrower-checked"], in: app); tap(app.buttons["staff-handover-next"], in: app) + tap(app.buttons["staff-handover-next"], in: app) + tap(app.buttons["staff-sample-photo"], in: app); tap(app.buttons["staff-upload-photo"], in: app) + for button in app.buttons.matching(identifier: "Start count").allElementsBoundByIndex { tap(button, in: app) } + XCTAssertTrue(app.buttons["staff-handover-confirm"].waitForExistence(timeout: 5)); attach("Staff issue evidence and review") + tap(app.buttons["staff-handover-confirm"], in: app) + XCTAssertFalse(app.descendants(matching: .any)["staff-handover-recorded"].firstMatch.exists) + tap(app.buttons["Record handover"], in: app) + XCTAssertTrue(app.descendants(matching: .any)["staff-handover-recorded"].firstMatch.waitForExistence(timeout: 10)); attach("Staff handover recorded") + } + func testEventScanShowsPersonBeforeAttendanceConfirmation() { + let app = staff(); tap(app.buttons["staff-events"], in: app) + tap(app.staticTexts["Sample repair workshop"].firstMatch, in: app) + tap(app.buttons["Scan check-in code"], in: app); scan("DEMO-EVENT", app: app) + XCTAssertTrue(app.buttons["staff-confirm-attendance"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.descendants(matching: .any)["staff-attendance-recorded"].firstMatch.exists); attach("Staff check-in review person") + tap(app.buttons["staff-confirm-attendance"], in: app) + XCTAssertTrue(app.descendants(matching: .any)["staff-attendance-recorded"].firstMatch.waitForExistence(timeout: 10)); attach("Staff attendance recorded") + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 57e19c4..690f1a1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,7 +1,46 @@ # Phase notes +## A6 Staff — implementation checkpoint (2026-10-17) + +Add the generated request queues, request decisions, QR resolution, container/kit preview, issue, +return, direct handout, booking decisions, event attendance and evidence operations. Staff tools +remain closed in production until a fresh native staff authority and the required makerspace actions +are documented by the server. A role name alone never unlocks them. The clearly labeled Debug staff +preview supplies independent per-makerspace fixtures and actual native flow UI. + +Handover progresses through code resolution, borrower/items, contextual camera permission, a bounded +JPEG evidence photo, review and explicit confirmation. Returns require their remark and resolutions; +attendance resolves the person and shows server status before confirmation. Native VisionKit uses an +AVFoundation fallback and manual entry when unavailable. Camera photos are normalized/downsampled, +re-encoded without location metadata, capped and reviewed before upload. Generated evidence grants +use exact POST fields with file last or raw PUT headers, with no app credentials on storage requests. + +Conflict, upload refusal, stale scope, duplicate item selection and invalid metadata remain recoverable +without issuing equipment locally. Receipts come from confirmed server results; pending callbacks +cannot mutate an invalidated workspace. This checkpoint adds26 state/API cases plus2 native staff +journeys; Mac compilation/runtime and phone/tablet captures remain pending. Physical cameras, +owner provisioning and real staff authority remain deployment validation gates. + +Parity: Staff tools / Pending requests / Accept request / Reject request / Issue equipment / Return +equipment / Direct handout / Take evidence photo / Confirm handout / Confirm return / Record handover / Confirm check-in. + + +Staff repositories also retain their accepted installation. A new replacement-account regression +requires zero queue, container-assignment or booking-approval requests and zero replacement +authentication callbacks after the old owner is retired. Native proof remains pending. + +Carry the A5 receipt visual follow-up into this full native gate: its initial screenshot showed only +an early blank QuickLook canvas, so it did not prove rendered receipt content. The existing payment +journey now waits at most 20 seconds for native Vision to recognize both unique sample PDF phrases +in the actual full-OS pixels before capture. On refusal it preserves the screenshot, native AX tree +and recognized text. No app marker, viewer replacement, fixed sleep or test removal is involved. +All earlier payment, request-review, staff and Release checks still run; native rendered-page proof +on phone and tablet remains pending. + ## A5 Payments — implementation checkpoint (2026-10-17) +Native build evidence (rendered-PDF follow-up pending): [`072275f` CI39004152168](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/39004152168) passed 188 native state/API cases and 22 native UI journeys on both phone and tablet, 18 Python generation/fixture checks, and the unsigned physical-device Release build, with no failed or skipped tests. The native outcome popover, awaiting confirmation, canceled/pending state and complete request-submit action were visually reviewed. Both PDF captures showed an early blank preview canvas; opening chrome does not prove rendered document content. The stricter actual-page check accompanies A6 and must demonstrate the receipt on both device sizes before visual acceptance. These are fixture/compiler results; real backend/provider/device setup remains a release gate. + Rerun every existing A4 journey with a stronger request-review check: a fitting button must be fully inside the visible content before tapping and before the screenshot. Cap review-only drag travel to the remaining exposed edge, accounting for the pan activation slop observed in native @@ -55,7 +94,9 @@ payment behavior are unchanged. The complete corrected phone/tablet and Release ## A4 Notifications and trusted routing — implementation checkpoint (2026-10-17) -Native build evidence (visual follow-up pending): [`a989989` CI38998510266](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/38998510266) passed 161 native state/API tests and 20 native UI journeys on both phone and tablet, 18 Python generation/fixture checks, and the unsigned physical-device Release build, with no failed or skipped tests. The phone before-submit screenshot was rejected because midpoint activation left the second label line below the screen. The stricter request-only visibility check is included with A5; its complete native rerun and fully visible phone screenshot remain required before visual acceptance. These are fixture/compiler results; real backend/provider/device validation remains a release gate. +Visual follow-up accepted: the complete two-line Submit request button is visible in the current phone capture, and [`072275f` CI39004152168](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/39004152168) reran every inherited journey successfully on phone and tablet. Request-review buttons that fit the viewport require their entire frame before activation; other app controls retain their prior native behavior. The native trace showed valid modal scroll extent: search resumed, but its old fixed half-budget reversal undid progress. Request-only direction now follows target edges with the same 24-attempt bound. The cause of individual injected gesture no-ops remains unproven; diagnostic overlays and the targeted workflow were removed before this accepted run. + +Native build evidence (visual follow-up pending): [`a989989` CI38998510266](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/38998510266) passed 161 native state/API tests and 20 native UI journeys on both phone and tablet, 18 Python generation/fixture checks, and the unsigned physical-device Release build, with no failed or skipped tests. The phone before-submit screenshot was rejected because midpoint activation left the second label line below the screen. The stricter request-only visibility check was included with A5; its complete native rerun and fully visible phone screenshot are accepted in the follow-up above. These are fixture/compiler results; real backend/provider/device validation remains a release gate. Wire the native notification delegate, contextual permission UI, per-account device-only Keychain registration metadata and generated create/delete operations. Consent differs from OS permission; diff --git a/scripts/build_staff_fixtures.py b/scripts/build_staff_fixtures.py new file mode 100644 index 0000000..df5326d --- /dev/null +++ b/scripts/build_staff_fixtures.py @@ -0,0 +1,71 @@ +"""Rebuild synthetic A6 fixtures from the pinned schema. These are never live server records.""" +import json +import pathlib + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SCHEMAS = json.loads((ROOT / "api/openapi.json").read_text(encoding="utf-8"))["components"]["schemas"] + + +def example(schema): + if schema.get("nullable"): + return None + if "$ref" in schema: + return example(SCHEMAS[schema["$ref"].split("/")[-1]]) + if "allOf" in schema: + return example(schema["allOf"][0]) + if "enum" in schema: + return schema["enum"][0] + kind = schema.get("type") + if kind == "object": + return {name: example(schema["properties"][name]) for name in schema.get("required", [])} + if kind == "array": + return [] + if kind == "boolean": + return False + if kind in ("integer", "number"): + return max(0, schema.get("minimum", 0)) + if kind == "string": + if schema.get("format") == "date-time": + return "2026-10-16T09:00:00Z" + if schema.get("format") == "uuid": + return "71000000-0000-4000-8000-000000000001" + if schema.get("format") == "decimal": + return "0.00" + return "" + return None + + +def model(schema_name, **changes): + return example(SCHEMAS[schema_name]) | changes + + +def build(): + item = model("AdminRequestItem", id=61, product_id=901, product_name="Sample precision toolkit", requested_quantity=2, + accepted_quantity=2, issued_quantity=2, tracking_mode="quantity", storage_location="Sample shelf A") + request = model("AdminRequest", id=601, makerspace_id=7, requester_username="meera", requester_name="Meera Nair", + requester_display="Meera Nair", requester_contact_email="meera@example.test", requester_contact_verified=True, + status="pending", items=[item], requested_for="Sample electronics project", assigned_box_label="Sample kit box") + fixtures = { + "request": request, + "loan": model("DirectLoan", id=701, target_type="product", target_label="Sample precision toolkit", status="active", + container_id=0, source="staff", items=[{"product_name": "Sample precision toolkit", "quantity": 2}], + return_items=[model("DirectLoanReturnItem", item_id=71, product_name="Sample precision toolkit", remaining_quantity=2, tracking_mode="quantity")]), + "members": model("PaginatedDirectLoanMemberList", count=1, results=[model("DirectLoanMember", user_id=51, membership_id=81, display_name="Meera Nair", username="meera")]), + "inventory": model("PaginatedInventoryProductAdminList", count=1, results=[model("InventoryProductAdmin", id=901, makerspace=7, name="Sample precision toolkit", tracking_mode="quantity", available_quantity=4, total_quantity=4, is_archived=False)]), + "containers": model("PaginatedBoxList", count=0, results=[]), + "event": model("EventAdmin", id=801, makerspace_id=7, title="Sample repair workshop", status="published", location="Sample workshop", capacity=20), + "space": model("BookableSpaceAdmin", id=901, makerspace_id=7, name="Sample project room", is_active=True, capacity=8), + "booking": model("BookingAdmin", id=1001, space_id=901, name="Meera Nair", email="meera@example.test", status="pending"), + "registration": model("EventRegistrationAdmin", id=1101, event_id=801, name="Meera Nair", email="meera@example.test", status="registered"), + "checkin": model("EventCheckInResolveResponse", registration_id=1101, name="Meera Nair", status="registered", payment_status="not_required", event_status="published", confirmable=True), + "kits": model("KitPreview", components=[model("KitExpectation", key="72000000-0000-4000-8000-000000000001", label="Sample probe", owner_label="Precision toolkit", quantity=2)]), + "qr": model("QrResolveResult", qr=model("QrCode", id=1201, makerspace=7, payload="DEMO-BOX", target_type="box", target_id=91, status="active"), target={"type": "box", "id": 91, "label": "Sample kit box"}, allowed_actions=["view", "contents", "record_scan"]), + } + directory = ROOT / "Fixtures/Staff" + directory.mkdir(parents=True, exist_ok=True) + for name, value in fixtures.items(): + (directory / (name + ".json")).write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + build() diff --git a/scripts/generate.py b/scripts/generate.py index 73f740b..4b759b6 100644 --- a/scripts/generate.py +++ b/scripts/generate.py @@ -115,6 +115,10 @@ def generate(raster=True): fixture_module = importlib.util.module_from_spec(fixture_spec) fixture_spec.loader.exec_module(fixture_module) fixture_module.generate_member_fixtures(ROOT) + staff_spec = importlib.util.spec_from_file_location("spaceworks_staff_fixtures", ROOT / "scripts/generate_staff_fixtures.py") + staff_module = importlib.util.module_from_spec(staff_spec) + staff_spec.loader.exec_module(staff_module) + staff_module.generate_staff_fixtures(ROOT) wordmark = assets / "Wordmark.imageset" wordmark.mkdir(parents=True, exist_ok=True) for name, master in [("light.png", "lockup-horizontal-one-colour-ink.png"), ("dark.png", "lockup-horizontal-reversed.png")]: diff --git a/scripts/generate_staff_fixtures.py b/scripts/generate_staff_fixtures.py new file mode 100644 index 0000000..6c29c63 --- /dev/null +++ b/scripts/generate_staff_fixtures.py @@ -0,0 +1,16 @@ +"""Compile synthetic staff fixtures into DEBUG-only constants, never application resources.""" +import pathlib + + +def generate_staff_fixtures(root): + root = pathlib.Path(root) + lines = ["// Generated synthetic A6 fixtures.", "#if DEBUG", "enum DebugStaffFixtures {"] + for source in sorted((root / "Fixtures/Staff").glob("*.json")): + value = source.read_text(encoding="utf-8").strip() + if not source.stem.isidentifier() or '"""#' in value or "\\#(" in value: + raise ValueError("Unsafe raw Swift literal") + lines.append(f' static let {source.stem} = #"""\n{value}\n"""#') + lines.extend(["}", "#endif", ""]) + destination = root / "SpaceWorks/Generated/DebugStaffFixtures.swift" + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text("\n".join(lines), encoding="utf-8") diff --git a/scripts/tests/test_staff_fixtures.py b/scripts/tests/test_staff_fixtures.py new file mode 100644 index 0000000..eaa1aca --- /dev/null +++ b/scripts/tests/test_staff_fixtures.py @@ -0,0 +1,62 @@ +"""Check synthetic staff data against the pinned OpenAPI types, rather than a second fixture model.""" +import json +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[2] +MODELS = { + "request": "AdminRequest", "loan": "DirectLoan", "members": "PaginatedDirectLoanMemberList", + "inventory": "PaginatedInventoryProductAdminList", "containers": "PaginatedBoxList", + "event": "EventAdmin", "space": "BookableSpaceAdmin", "booking": "BookingAdmin", + "registration": "EventRegistrationAdmin", "checkin": "EventCheckInResolveResponse", + "kits": "KitPreview", "qr": "QrResolveResult", +} + + +class StaffFixturesTests(unittest.TestCase): + models = MODELS + directory = "Staff" + def test_synthetic_records_satisfy_authoritative_required_fields_enums_types_and_bounds(self): + schemas = json.loads((ROOT / "api/openapi.json").read_text(encoding="utf-8"))["components"]["schemas"] + + def check(value, schema, path): + if value is None and schema.get("nullable"): + return + if "$ref" in schema: + return check(value, schemas[schema["$ref"].split("/")[-1]], path) + if "allOf" in schema: + for component in schema["allOf"]: + check(value, component, path) + return + if "enum" in schema: + self.assertIn(value, schema["enum"], path) + kind = schema.get("type") + if kind == "object": + self.assertIsInstance(value, dict, path) + for key in schema.get("required", []): + self.assertIn(key, value, path) + for key, child in value.items(): + if key in schema.get("properties", {}): + check(child, schema["properties"][key], path + "." + key) + elif kind == "array": + self.assertIsInstance(value, list, path) + for index, child in enumerate(value): + check(child, schema["items"], path + "." + str(index)) + elif kind in ("integer", "number"): + self.assertIsInstance(value, (int, float), path) + self.assertNotIsInstance(value, bool, path) + self.assertGreaterEqual(value, schema.get("minimum", value), path) + self.assertLessEqual(value, schema.get("maximum", value), path) + elif kind == "string": + self.assertIsInstance(value, str, path) + elif kind == "boolean": + self.assertIsInstance(value, bool, path) + + for filename, model in self.models.items(): + with self.subTest(model=model): + fixture = json.loads((ROOT / "Fixtures" / self.directory / (filename + ".json")).read_text(encoding="utf-8")) + check(fixture, schemas[model] if isinstance(model, str) else model, filename) + + +if __name__ == "__main__": + unittest.main() From c49f55b14391c4c03b334c832782d69279a824b7 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:48:05 +0530 Subject: [PATCH 36/98] fix(a6): isolate child cleanup and retain demo quantities Co-Authored-By: Codex --- SpaceWorks/StaffDemo.swift | 127 ++++++++++++++++++---- SpaceWorks/StaffState.swift | 3 +- SpaceWorksTests/StaffTests.swift | 88 +++++++++++++++ SpaceWorksUITests/PaymentsFlowTests.swift | 7 +- docs/CHANGELOG.md | 33 ++++-- 5 files changed, 225 insertions(+), 33 deletions(-) diff --git a/SpaceWorks/StaffDemo.swift b/SpaceWorks/StaffDemo.swift index c68f98a..5c5c084 100644 --- a/SpaceWorks/StaffDemo.swift +++ b/SpaceWorks/StaffDemo.swift @@ -15,8 +15,9 @@ actor DemoStaffRepository: StaffRepository { let makerspaceID: Int private var statuses = [601: "pending", 602: "accepted", 603: "issued"] private var assignments = Set() - private var directCreated = false - private var directReturned = false + private var requestRecords: [Int: Components.Schemas.AdminRequest] = [:] + private var loanRecords: [Int: Components.Schemas.DirectLoan] = [:] + private var nextLoanID = 702 private var attended = false private var bookingStatus = "pending" private var evidenceCounter = 1300 @@ -47,37 +48,88 @@ actor DemoStaffRepository: StaffRepository { let rows: [Components.Schemas.AdminRequest] = try statuses.keys.sorted().filter { statuses[$0] == target }.map { try request($0) } return try self.page(rows, page: page) } + private func changed() -> APIError { + APIError(status: 409, code: "changed", detail: "This changed. Refresh and try again.") + } private func request(_ id: Int) throws -> Components.Schemas.AdminRequest { - guard let status = statuses[id] else { throw APIError(status: 404, code: "not_found", detail: "Sample request unavailable.") } - var object = try JSONSerialization.jsonObject(with: Data(DebugStaffFixtures.request.utf8)) as! [String: Any] - var items = object["items"] as! [[String: Any]] - items[0]["issued_quantity"] = status == "issued" || status == "closed" ? 2 : 0 - items[0]["returned_quantity"] = status == "closed" ? 2 : 0 - object["items"] = items - return try decode(String(decoding: JSONSerialization.data(withJSONObject: object), as: UTF8.self), changes: ["id": id, "status": status]) + try checked() + if let saved = requestRecords[id] { return saved } + guard let status = statuses[id] else { throw changed() } + var row: Components.Schemas.AdminRequest = try decode(DebugStaffFixtures.request, changes: ["id": id, "status": status]) + row.items = row.items.map { initial in + var item = initial + item.accepted_quantity = status == "pending" ? 0 : item.requested_quantity + item.issued_quantity = status == "issued" ? item.accepted_quantity : 0 + item.returned_quantity = 0; item.damaged_quantity = 0; item.missing_quantity = 0 + return item + } + return row + } + private func save(_ row: Components.Schemas.AdminRequest) -> Components.Schemas.AdminRequest { + requestRecords[row.id] = row; statuses[row.id] = row.status; return row } func accept(id: Int, body: Components.Schemas.AcceptRequest) throws -> Components.Schemas.AdminRequest { - try checked(); statuses[id] = "accepted"; return try request(id) + var row = try request(id) + guard row.status == "pending" else { throw changed() } + let values = body.accepted_quantities ?? row.items.map { .init(item_id: $0.id, quantity: $0.requested_quantity) } + guard Set(values.map(\.item_id)).count == values.count, + values.allSatisfy({ value in row.items.contains { $0.id == value.item_id && value.quantity >= 0 && value.quantity <= $0.requested_quantity } }), + values.contains(where: { $0.quantity > 0 }) else { throw changed() } + row.items = row.items.map { initial in + var item = initial; item.accepted_quantity = values.first { $0.item_id == item.id }?.quantity ?? 0; return item + } + row.status = "accepted"; return save(row) } func reject(id: Int, body: Components.Schemas.RejectRequest) throws -> Components.Schemas.AdminRequest { - try checked(); statuses[id] = "rejected"; return try request(id) + var row = try request(id) + guard row.status == "pending", !body.reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw changed() } + row.status = "rejected"; row.rejection_reason = body.reason; return save(row) } func assignBox(id: Int, body: Components.Schemas.AssignBox) throws -> Components.Schemas.AdminRequest { - try checked(); guard statuses[id] == "accepted", body.box_code == "DEMO-BOX" else { throw APIError(status: 409, code: "changed", detail: "Sample container or request changed.") } - assignments.insert(id); return try request(id) + let row = try request(id) + guard row.status == "accepted", body.box_code == "DEMO-BOX" else { throw changed() } + assignments.insert(id); return row } func issue(id: Int, body: Components.Schemas.IssueRequest) throws -> Components.Schemas.AdminRequest { - try checked() + var row = try request(id) if scenario == .issueConflict { throw APIError(status: 409, code: "issue_conflict", detail: "The request changed before issue completed.") } - guard statuses[id] == "accepted", assignments.contains(id) else { throw APIError(status: 409, code: "changed", detail: "Refresh the sample request.") } - statuses[id] = "issued"; return try request(id) + guard row.status == "accepted", assignments.contains(id), body.evidence_id > 0, + row.items.contains(where: { $0.accepted_quantity > 0 }) else { throw changed() } + row.items = row.items.map { initial in var item = initial; item.issued_quantity = item.accepted_quantity; return item } + row.status = "issued"; row.issue_evidence_id = body.evidence_id; return save(row) + } + private func counts(_ resolution: Components.Schemas.ReturnItemResolution, remaining: Int) throws -> (returned: Int, damaged: Int, missing: Int, remaining: Int) { + let returned = resolution.returned ?? 0, damaged = resolution.damaged ?? 0, missing = resolution.missing ?? 0 + var left = remaining + for count in [returned, damaged, missing] { + guard count >= 0, count <= left else { throw changed() }; left -= count + } + guard resolution.assets?.isEmpty != false else { throw changed() } + return (returned, damaged, missing, left) } func returnRequest(id: Int, body: Components.Schemas.ReturnRequest) throws -> Components.Schemas.AdminRequest { - try checked(); statuses[id] = "closed"; return try request(id) + var row = try request(id) + guard row.status == "issued", body.evidence_id > 0, body.box_code == "DEMO-BOX", + !body.remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + Set(body.resolutions.map(\.item_id)).count == body.resolutions.count else { throw changed() } + var recorded = false + for resolution in body.resolutions { + guard let index = row.items.firstIndex(where: { $0.id == resolution.item_id }), + let remaining = StaffMetadata.remaining(issued: row.items[index].issued_quantity, returned: row.items[index].returned_quantity, + damaged: row.items[index].damaged_quantity, missing: row.items[index].missing_quantity) else { throw changed() } + let delta = try counts(resolution, remaining: remaining) + row.items[index].returned_quantity += delta.returned; row.items[index].damaged_quantity += delta.damaged; row.items[index].missing_quantity += delta.missing + recorded = recorded || delta.remaining < remaining + } + guard recorded else { throw changed() } + row.return_evidence_ids += try JSONDecoder().decode(type(of: row.return_evidence_ids), from: Data("[\(body.evidence_id)]".utf8)) + row.status = row.items.allSatisfy { StaffMetadata.remaining(issued: $0.issued_quantity, returned: $0.returned_quantity, damaged: $0.damaged_quantity, missing: $0.missing_quantity) == 0 } ? "closed" : "issued" + return save(row) } func resolveQR(body: Components.Schemas.QrResolve) throws -> Components.Schemas.QrResolveResult { guard ["DEMO-BOX", "DEMO-TOOL"].contains(body.payload) else { throw APIError(status: 404, code: "not_found", detail: "Use DEMO-BOX or DEMO-TOOL in this sample.") } var object = try JSONSerialization.jsonObject(with: Data(DebugStaffFixtures.qr.utf8)) as! [String: Any] + var scopedQR = object["qr"] as! [String: Any]; scopedQR["makerspace"] = makerspaceID; object["qr"] = scopedQR if body.payload == "DEMO-TOOL" { var qr = object["qr"] as! [String: Any]; qr["id"] = 1202; qr["payload"] = body.payload; qr["target_type"] = "product"; qr["target_id"] = 901; object["qr"] = qr object["target"] = ["type": "product", "id": 901, "label": "Sample precision toolkit", "product_id": 901] @@ -85,9 +137,13 @@ actor DemoStaffRepository: StaffRepository { } return try decode(String(decoding: JSONSerialization.data(withJSONObject: object), as: UTF8.self)) } + private func initialLoan() throws -> Components.Schemas.DirectLoan { + if let saved = loanRecords[701] { return saved } + let row: Components.Schemas.DirectLoan = try decode(DebugStaffFixtures.loan); loanRecords[row.id] = row; return row + } func directLoans(page: Int) throws -> Components.Schemas.PaginatedDirectLoanList { - let row: Components.Schemas.DirectLoan = try decode(DebugStaffFixtures.loan, changes: ["status": directReturned ? "returned" : "active"]) - return try self.page([row], page: page) + try checked(); _ = try initialLoan() + return try self.page(loanRecords.keys.sorted().compactMap { loanRecords[$0] }, page: page) } func directMembers(page: Int) throws -> Components.Schemas.PaginatedDirectLoanMemberList { let initial: Components.Schemas.PaginatedDirectLoanMemberList = try decode(DebugStaffFixtures.members) @@ -99,10 +155,39 @@ actor DemoStaffRepository: StaffRepository { } func containers(page: Int) throws -> Components.Schemas.PaginatedBoxList { try decode(DebugStaffFixtures.containers) } func createDirect(body: Components.Schemas.DirectLoanIssue) throws -> Components.Schemas.DirectLoan { - try checked(); directCreated = true; return try decode(DebugStaffFixtures.loan) + try checked() + let items = body.items ?? [], scans = body.qr_payloads ?? [] + guard body.borrower_id == 51, body.evidence_id > 0, body.container_id == nil, + Set(items.map(\.product_id)).count == items.count, + items.allSatisfy({ $0.product_id == 901 && (1...4).contains($0.quantity) }), + Set(scans).count == scans.count, scans.allSatisfy({ $0 == "DEMO-TOOL" }), + items.isEmpty || scans.isEmpty else { throw changed() } + let quantity = items.first?.quantity ?? (scans.isEmpty ? 0 : 1) + guard quantity > 0 else { throw changed() } + var row = try initialLoan() + row.id = nextLoanID; nextLoanID += 1 + row.public_token = String(format: "71000000-0000-4000-8000-%012lld", Int64(row.id)) + row.status = "active"; row.issue_evidence_id = body.evidence_id; row.return_evidence_id = nil; row.return_notes = "" + row.items = [.init(product_name: row.target_label, quantity: quantity)] + row.return_items = [.init(item_id: row.id * 100 + 1, product_name: row.target_label, remaining_quantity: quantity, tracking_mode: "quantity", assets: [])] + loanRecords[row.id] = row; return row } func returnDirect(id: Int, body: Components.Schemas.DirectLoanReturn) throws -> Components.Schemas.DirectLoan { - try checked(); directReturned = true; return try decode(DebugStaffFixtures.loan, changes: ["status": "returned"]) + try checked(); _ = try initialLoan() + guard var row = loanRecords[id], row.status == "active", body.evidence_id > 0, + !body.notes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let resolutions = body.resolutions, Set(resolutions.map(\.item_id)).count == resolutions.count else { throw changed() } + var recorded = false + for resolution in resolutions { + guard let index = row.return_items.firstIndex(where: { $0.item_id == resolution.item_id }) else { throw changed() } + let remaining = row.return_items[index].remaining_quantity + let delta = try counts(resolution, remaining: remaining) + row.return_items[index].remaining_quantity = delta.remaining; recorded = recorded || delta.remaining < remaining + } + guard recorded else { throw changed() } + row.return_notes = body.notes; row.return_evidence_id = body.evidence_id + row.status = row.return_items.allSatisfy { $0.remaining_quantity == 0 } ? "returned" : "active" + loanRecords[id] = row; return row } func evidence(body: Components.Schemas.EvidenceUrlRequest) async throws -> Components.Schemas.EvidenceUrlResponse { await beforeEvidence() diff --git a/SpaceWorks/StaffState.swift b/SpaceWorks/StaffState.swift index 71426ac..d6f32de 100644 --- a/SpaceWorks/StaffState.swift +++ b/SpaceWorks/StaffState.swift @@ -35,7 +35,8 @@ final class StaffState { private var selectedSpace: Int? private var active = true @ObservationIgnored private var cooldownTask: Task? - private var children: [() -> Void] = [] + // Destination construction registers weak cleanup callbacks, which must not invalidate views. + @ObservationIgnored private var children: [() -> Void] = [] init(repository: any StaffRepository, makerspaceID: Int, actions: Set, features: Set) { self.repository = repository; self.makerspaceID = makerspaceID self.actions = actions; self.features = features diff --git a/SpaceWorksTests/StaffTests.swift b/SpaceWorksTests/StaffTests.swift index 8a54795..6cf8742 100644 --- a/SpaceWorksTests/StaffTests.swift +++ b/SpaceWorksTests/StaffTests.swift @@ -1,4 +1,5 @@ import Foundation +import Observation import XCTest import SpaceWorksAPI @testable import SpaceWorks @@ -18,6 +19,13 @@ private enum StaffFixture { } } +private final class StaffObservationChanges: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + var count: Int { lock.lock(); defer { lock.unlock() }; return value } + func record() { lock.lock(); value += 1; lock.unlock() } +} + #if DEBUG @MainActor final class StaffTests: XCTestCase { private func state(_ repository: DemoStaffRepository = DemoStaffRepository(), kits: Bool = false, actions: Set = StaffFixture.actions) -> StaffState { @@ -32,6 +40,19 @@ private enum StaffFixture { handover.setPhoto(photo); await handover.uploadPhoto(); await handover.prepareKits() return handover } + func testChildConstructionDoesNotInvalidateParentViewObservation() throws { + let parent = state() + let event: Components.Schemas.EventAdmin = try StaffFixture.decode(DebugStaffFixtures.event) + let changes = StaffObservationChanges() + let first = withObservationTracking { + StaffCheckInState(parent: parent, event: event) + } onChange: { changes.record() } + let second = StaffCheckInState(parent: parent, event: event) + XCTAssertEqual(changes.count, 0, "Registering cleanup must not rebuild a destination that constructs child state.") + parent.invalidate() + XCTAssertEqual(changes.count, 1, "Actual scope invalidation remains observable.") + XCTAssertEqual(first.eventTitle, ""); XCTAssertEqual(second.eventTitle, "") + } func testDemoStaffRowsMatchTheirActualSelectedMakerspace() async throws { let repository = DemoStaffRepository(makerspaceID: 8) let parent = StaffState(repository: repository, makerspaceID: 8, actions: StaffFixture.actions, features: []) @@ -40,12 +61,79 @@ private enum StaffFixture { await parent.accept(row, quantities: [61: 2]); XCTAssertNil(parent.error) parent.queue = .accepted; await parent.loadQueue() XCTAssertTrue(parent.requests.allSatisfy { $0.makerspace_id == 8 }) + for payload in ["DEMO-BOX", "DEMO-TOOL"] { + let resolved = try await repository.resolveQR(body: .init(payload: payload)) + XCTAssertEqual(resolved.qr.makerspace, 8) + } } func testAcceptMovesActualRequestIntoAcceptedQueue() async throws { let parent = state(); await parent.loadQueue(); let row = try XCTUnwrap(parent.requests.first) await parent.accept(row, quantities: [61: 2]); XCTAssertNil(parent.error); XCTAssertTrue(parent.requests.isEmpty) parent.queue = .accepted; await parent.loadQueue(); XCTAssertTrue(parent.requests.contains { $0.id == row.id }) } + func testPartialAcceptancePersistsThroughIssueAndQueueRefetch() async throws { + let parent = state(); await parent.loadQueue(); let pending = try XCTUnwrap(parent.requests.first) + XCTAssertEqual(pending.items.first?.accepted_quantity, 0); XCTAssertEqual(pending.items.first?.issued_quantity, 0) + await parent.accept(pending, quantities: [61: 1]); XCTAssertNil(parent.error) + parent.queue = .accepted; await parent.loadQueue() + let accepted = try XCTUnwrap(parent.requests.first { $0.id == pending.id }) + XCTAssertEqual(accepted.items.first?.accepted_quantity, 1); XCTAssertEqual(accepted.items.first?.issued_quantity, 0) + let handover = StaffHandoverState(parent: parent, mode: .issue(accepted)) + await handover.scan("DEMO-BOX", box: true); handover.borrowerChecked = true + handover.setPhoto(photo); await handover.uploadPhoto(); XCTAssertTrue(handover.ready) + let evidence = handover.evidenceID + await handover.confirm(); XCTAssertNotNil(handover.receipt) + parent.queue = .active; await parent.loadQueue() + let issued = try XCTUnwrap(parent.requests.first { $0.id == pending.id }) + XCTAssertEqual(issued.items.first?.accepted_quantity, 1); XCTAssertEqual(issued.items.first?.issued_quantity, 1) + XCTAssertEqual(issued.issue_evidence_id, evidence) + } + func testPartialRequestReturnKeepsOutstandingQuantityUntilFinalResolution() async throws { + let repository = DemoStaffRepository(); let parent = state(repository) + parent.queue = .active; await parent.loadQueue(); let initial = try XCTUnwrap(parent.requests.first) + let handover = StaffHandoverState(parent: parent, mode: .returnRequest(initial)) + handover.returns[0].returned = 1; await handover.scan("DEMO-BOX", box: true) + handover.remark = "One item inspected and returned."; handover.setPhoto(photo); await handover.uploadPhoto() + XCTAssertTrue(handover.ready); await handover.confirm(); XCTAssertNotNil(handover.receipt) + await parent.loadQueue(); let partial = try XCTUnwrap(parent.requests.first { $0.id == initial.id }) + XCTAssertEqual(partial.status, "issued"); XCTAssertEqual(partial.items[0].returned_quantity, 1) + XCTAssertEqual(partial.return_evidence_ids.count, 1) + XCTAssertEqual(StaffHandoverState(parent: parent, mode: .returnRequest(partial)).returns[0].remaining, 1) + for resolution in [Components.Schemas.ReturnItemResolution(item_id: 999, returned: 1), .init(item_id: 61, returned: 2)] { + do { _ = try await repository.returnRequest(id: initial.id, body: .init(evidence_id: 1400, box_code: "DEMO-BOX", remark: "Checked", resolutions: [resolution])); XCTFail("Invalid return changed sample records") } + catch {} + } + await parent.loadQueue(); XCTAssertEqual(parent.requests.first?.items[0].returned_quantity, 1) + let closed = try await repository.returnRequest(id: initial.id, body: .init(evidence_id: 1401, box_code: "DEMO-BOX", remark: "Remaining item missing.", resolutions: [.init(item_id: 61, missing: 1)])) + XCTAssertEqual(closed.status, "closed"); XCTAssertEqual(closed.items[0].returned_quantity, 1) + XCTAssertEqual(closed.items[0].missing_quantity, 1); XCTAssertEqual(closed.return_evidence_ids.count, 2) + await parent.loadQueue(); XCTAssertFalse(parent.requests.contains { $0.id == initial.id }) + } + func testDirectSelectedAndScannedQuantitiesPersistAcrossPartialReturns() async throws { + let repository = DemoStaffRepository() + let one = try await repository.createDirect(body: .init(borrower_id: 51, evidence_id: 1401, items: [.init(product_id: 901, quantity: 1)])) + let two = try await repository.createDirect(body: .init(borrower_id: 51, evidence_id: 1402, items: [.init(product_id: 901, quantity: 2)])) + let scanned = try await repository.createDirect(body: .init(borrower_id: 51, evidence_id: 1403, qr_payloads: ["DEMO-TOOL"])) + XCTAssertEqual(one.items.first?.quantity, 1); XCTAssertEqual(two.items.first?.quantity, 2); XCTAssertEqual(scanned.items.first?.quantity, 1) + XCTAssertNotEqual(one.id, two.id); XCTAssertNotEqual(one.return_items.first?.item_id, two.return_items.first?.item_id) + XCTAssertEqual(two.issue_evidence_id, 1402) + let itemID = try XCTUnwrap(two.return_items.first?.item_id) + _ = try await repository.returnDirect(id: two.id, body: .init(evidence_id: 1404, notes: "One damaged item inspected.", resolutions: [.init(item_id: itemID, damaged: 1)])) + let refreshed = try await repository.directLoans(page: 1) + let partial = try XCTUnwrap(refreshed.results.first { $0.id == two.id }) + XCTAssertEqual(partial.status, "active"); XCTAssertEqual(partial.return_items.first?.remaining_quantity, 1) + XCTAssertEqual(partial.return_evidence_id, 1404) + XCTAssertEqual(refreshed.results.first { $0.id == one.id }?.return_items.first?.remaining_quantity, 1) + for resolution in [Components.Schemas.ReturnItemResolution(item_id: 71, returned: 1), .init(item_id: itemID, returned: 2)] { + do { _ = try await repository.returnDirect(id: two.id, body: .init(evidence_id: 1405, notes: "Checked", resolutions: [resolution])); XCTFail("Invalid direct return changed sample records") } + catch {} + } + let final = try await repository.returnDirect(id: two.id, body: .init(evidence_id: 1406, notes: "Last item returned.", resolutions: [.init(item_id: itemID, returned: 1)])) + XCTAssertEqual(final.status, "returned"); XCTAssertEqual(final.return_items.first?.remaining_quantity, 0) + let finalList = try await repository.directLoans(page: 1) + XCTAssertEqual(finalList.results.first { $0.id == two.id }?.status, "returned") + XCTAssertEqual(finalList.results.first { $0.id == two.id }?.issue_evidence_id, 1402) + } func testRejectionNeedsReasonAndSelectedCapability() async throws { let parent = state(); await parent.loadQueue(); let row = try XCTUnwrap(parent.requests.first) await parent.reject(row, reason: " "); XCTAssertNotNil(parent.error); XCTAssertEqual(parent.requests.count, 1) diff --git a/SpaceWorksUITests/PaymentsFlowTests.swift b/SpaceWorksUITests/PaymentsFlowTests.swift index 570f843..4a243de 100644 --- a/SpaceWorksUITests/PaymentsFlowTests.swift +++ b/SpaceWorksUITests/PaymentsFlowTests.swift @@ -38,14 +38,17 @@ final class PaymentsFlowTests: XCTestCase { } private func waitForRenderedReceipt(in app: XCUIApplication) { var lastRecognizedText = "" + var lastRecognitionError = "None." let visibleReceipt = NSPredicate { _, _ in guard let image = XCUIScreen.main.screenshot().image.cgImage else { return false } let request = VNRecognizeTextRequest() request.recognitionLevel = .accurate request.recognitionLanguages = ["en-US"] request.usesLanguageCorrection = false + // The PDF body is small relative to the full-screen image; keep it in OCR coverage. + request.minimumTextHeight = 0.005 do { try VNImageRequestHandler(cgImage: image, options: [:]).perform([request]) } - catch { return false } + catch { lastRecognitionError = String(describing: error); return false } lastRecognizedText = (request.results ?? []).compactMap { $0.topCandidates(1).first?.string }.joined(separator: " ") let text = lastRecognizedText.uppercased() // Both phrases occur in the actual PDF page, never in the backing document list. @@ -59,6 +62,8 @@ final class PaymentsFlowTests: XCTestCase { hierarchy.name = "Native receipt accessibility hierarchy"; hierarchy.lifetime = .keepAlways; add(hierarchy) let recognized = XCTAttachment(string: lastRecognizedText) recognized.name = "Native receipt visible text"; recognized.lifetime = .keepAlways; add(recognized) + let recognition = XCTAttachment(string: "minimumTextHeight=0.005; last Vision error: \(lastRecognitionError)") + recognition.name = "Native receipt recognition diagnostics"; recognition.lifetime = .keepAlways; add(recognition) } XCTAssertEqual(result, .completed, "The actual native PDF page must render its sample receipt before capture.") } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 690f1a1..78edf3c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## A6 Staff — implementation checkpoint (2026-10-17) +Initial native evidence: [`eaa400d` CI39006807733](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/39006807733) passed all 215 native state/API cases and the 22 earlier UI journeys on both phone and tablet. The two new staff journeys hung while opening their destinations, so this phase was not accepted and the unsigned physical-device Release step was skipped. Destination construction registered weak child-cleanup callbacks on an observable array. That bookkeeping now avoids view observation; a focused actual-child construction regression checks that registration does not invalidate the parent view while scope invalidation still clears both children. The corrected 219-native/24-UI complete gate remains pending. + Add the generated request queues, request decisions, QR resolution, container/kit preview, issue, return, direct handout, booking decisions, event attendance and evidence operations. Staff tools remain closed in production until a fresh native staff authority and the required makerspace actions @@ -17,8 +19,8 @@ use exact POST fields with file last or raw PUT headers, with no app credentials Conflict, upload refusal, stale scope, duplicate item selection and invalid metadata remain recoverable without issuing equipment locally. Receipts come from confirmed server results; pending callbacks -cannot mutate an invalidated workspace. This checkpoint adds26 state/API cases plus2 native staff -journeys; Mac compilation/runtime and phone/tablet captures remain pending. Physical cameras, +cannot mutate an invalidated workspace. This phase adds31 state/API cases plus2 native staff +journeys; the corrected native gate and staff phone/tablet captures remain pending. Physical cameras, owner provisioning and real staff authority remain deployment validation gates. Parity: Staff tools / Pending requests / Accept request / Reject request / Issue equipment / Return @@ -29,17 +31,28 @@ Staff repositories also retain their accepted installation. A new replacement-ac requires zero queue, container-assignment or booking-approval requests and zero replacement authentication callbacks after the old owner is retired. Native proof remains pending. -Carry the A5 receipt visual follow-up into this full native gate: its initial screenshot showed only -an early blank QuickLook canvas, so it did not prove rendered receipt content. The existing payment -journey now waits at most 20 seconds for native Vision to recognize both unique sample PDF phrases -in the actual full-OS pixels before capture. On refusal it preserves the screenshot, native AX tree -and recognized text. No app marker, viewer replacement, fixed sleep or test removal is involved. -All earlier payment, request-review, staff and Release checks still run; native rendered-page proof -on phone and tablet remains pending. +The A5 receipt visual follow-up passed on both device sizes in the initial A6 native checkpoint. +Native Vision recognized both unique sample PDF phrases in actual full-OS pixels within the unchanged +20-second bound; independent review confirmed the rendered receipt and usable Done/share controls. +The prior blank images were premature captures. Retain this actual-page gate in every later run, +explicitly include small text in OCR coverage and attach Vision errors on refusal. No app marker, +viewer replacement, fixed sleep or test removal is involved. + +The Debug staff repository now retains generated request and direct-loan records after each +confirmed mutation. Acceptance quantities survive issue and queue refresh; partial returned, +damaged or missing counts remain outstanding until every issued item is resolved. Separate direct +loans retain their actual selected/scanned quantities, distinct local item IDs, evidence and return +notes. Invalid, duplicate or overremaining resolutions are rejected before committing any record. +Sample QR codes also carry the currently selected makerspace, including the second demo space. + +Three new refetch regressions cover partial acceptance/issue, cumulative request returns and +independent direct-loan quantities/returns. The existing makerspace test checks both QR codes. +This increases the intended complete A6 native total to 219, with 24 UI journeys and 19 Python cases; +new native execution remains pending. The change does not alter the live API or contract. ## A5 Payments — implementation checkpoint (2026-10-17) -Native build evidence (rendered-PDF follow-up pending): [`072275f` CI39004152168](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/39004152168) passed 188 native state/API cases and 22 native UI journeys on both phone and tablet, 18 Python generation/fixture checks, and the unsigned physical-device Release build, with no failed or skipped tests. The native outcome popover, awaiting confirmation, canceled/pending state and complete request-submit action were visually reviewed. Both PDF captures showed an early blank preview canvas; opening chrome does not prove rendered document content. The stricter actual-page check accompanies A6 and must demonstrate the receipt on both device sizes before visual acceptance. These are fixture/compiler results; real backend/provider/device setup remains a release gate. +Native build evidence (rendered-PDF follow-up pending): [`072275f` CI39004152168](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/39004152168) passed 188 native state/API cases and 22 native UI journeys on both phone and tablet, 18 Python generation/fixture checks, and the unsigned physical-device Release build, with no failed or skipped tests. The native outcome popover, awaiting confirmation, canceled/pending state and complete request-submit action were visually reviewed. Both PDF captures showed an early blank preview canvas; opening chrome does not prove rendered document content. The actual-page follow-up in [`eaa400d` CI39006807733](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/39006807733) recognized both page phrases and independently demonstrated the complete rendered receipt on both sizes; A6 staff navigation still needs its corrected full gate. These are fixture/compiler results; real backend/provider/device setup remains a release gate. Rerun every existing A4 journey with a stronger request-review check: a fitting button must be fully inside the visible content before tapping and before the screenshot. Cap review-only drag From 0c5c38b8ed71500f5df0e2fc9f5351fc3d8735cd Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:48:30 +0530 Subject: [PATCH 37/98] test(a6): activate the native handover confirmation Co-Authored-By: Codex --- SpaceWorksUITests/StaffFlowTests.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/SpaceWorksUITests/StaffFlowTests.swift b/SpaceWorksUITests/StaffFlowTests.swift index e38d9e3..9379fd3 100644 --- a/SpaceWorksUITests/StaffFlowTests.swift +++ b/SpaceWorksUITests/StaffFlowTests.swift @@ -28,6 +28,18 @@ final class StaffFlowTests: XCTestCase { let field = app.textFields["scanner-manual-code"] tap(field, in: app); field.typeText(payload + "\n") } + private func recordHandover(in app: XCUIApplication) { + // Native confirmation dialogs have separate title/action scroll regions. + XCTAssertTrue(app.staticTexts["Record this handout?"].waitForExistence(timeout: 5)) + let action = app.buttons["Record handover"] + XCTAssertTrue(action.waitForExistence(timeout: 5)); XCTAssertTrue(action.isHittable) + let frame = action.frame + XCTAssertTrue([frame.minX, frame.minY, frame.width, frame.height].allSatisfy(\.isFinite)) + XCTAssertGreaterThan(frame.width, 0); XCTAssertGreaterThan(frame.height, 0) + XCTAssertTrue(app.frame.insetBy(dx: 2, dy: 2).contains(frame)) + attach("Native staff handover confirmation") + action.tap() + } private func attach(_ name: String) { let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); attachment.name = name; attachment.lifetime = .keepAlways; add(attachment) } @@ -45,7 +57,7 @@ final class StaffFlowTests: XCTestCase { XCTAssertTrue(app.buttons["staff-handover-confirm"].waitForExistence(timeout: 5)); attach("Staff issue evidence and review") tap(app.buttons["staff-handover-confirm"], in: app) XCTAssertFalse(app.descendants(matching: .any)["staff-handover-recorded"].firstMatch.exists) - tap(app.buttons["Record handover"], in: app) + recordHandover(in: app) XCTAssertTrue(app.descendants(matching: .any)["staff-handover-recorded"].firstMatch.waitForExistence(timeout: 10)); attach("Staff handover recorded") } func testEventScanShowsPersonBeforeAttendanceConfirmation() { From 36fd8530b59c15237f7053c73d8e3c0dcbfc4988 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:34:55 +0530 Subject: [PATCH 38/98] fix(a6): preserve staff controls through native transitions Co-Authored-By: Codex --- SpaceWorks/StaffViews.swift | 2 +- SpaceWorksUITests/StaffFlowTests.swift | 22 ++++++++++++++++++---- docs/CHANGELOG.md | 2 ++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/SpaceWorks/StaffViews.swift b/SpaceWorks/StaffViews.swift index eed3b5f..9d7ea7a 100644 --- a/SpaceWorks/StaffViews.swift +++ b/SpaceWorks/StaffViews.swift @@ -177,7 +177,7 @@ private struct StaffCheckInView: View { Text("Payment: \((person.payment_status ?? "Unavailable").replacingOccurrences(of: "_", with: " "))").foregroundStyle(Color.swMuted) if state.canConfirm { Button("Confirm attendance") { Task { await state.confirm() } }.buttonStyle(SWPrimary()).accessibilityIdentifier("staff-confirm-attendance") } else { Text("This registration cannot be checked in. Scan another code or ask the event manager.").foregroundStyle(Color.swMuted) } - }.accessibilityIdentifier("staff-resolved-person") + } } } }.padding(24).frame(maxWidth: 720).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle("Check-in").navigationBarTitleDisplayMode(.inline) diff --git a/SpaceWorksUITests/StaffFlowTests.swift b/SpaceWorksUITests/StaffFlowTests.swift index 9379fd3..b819a5a 100644 --- a/SpaceWorksUITests/StaffFlowTests.swift +++ b/SpaceWorksUITests/StaffFlowTests.swift @@ -21,12 +21,20 @@ final class StaffFlowTests: XCTestCase { } tap(profile, in: app) } - tap(app.buttons["staff-tools"], in: app) + let tools = app.buttons["staff-tools"] + tap(tools, in: app) + // Wait for outgoing Profile content to leave the AX tree before querying new scroll views. + let outgoingGone = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: tools) + XCTAssertEqual(XCTWaiter.wait(for: [outgoingGone], timeout: 5), .completed) + XCTAssertTrue(app.navigationBars["Staff tools"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.buttons["staff-requests"].waitForExistence(timeout: 5)) return app } private func scan(_ payload: String, app: XCUIApplication) { let field = app.textFields["scanner-manual-code"] tap(field, in: app); field.typeText(payload + "\n") + let scannerClosed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: field) + XCTAssertEqual(XCTWaiter.wait(for: [scannerClosed], timeout: 5), .completed) } private func recordHandover(in app: XCUIApplication) { // Native confirmation dialogs have separate title/action scroll regions. @@ -53,9 +61,15 @@ final class StaffFlowTests: XCTestCase { tap(app.switches["staff-borrower-checked"], in: app); tap(app.buttons["staff-handover-next"], in: app) tap(app.buttons["staff-handover-next"], in: app) tap(app.buttons["staff-sample-photo"], in: app); tap(app.buttons["staff-upload-photo"], in: app) - for button in app.buttons.matching(identifier: "Start count").allElementsBoundByIndex { tap(button, in: app) } - XCTAssertTrue(app.buttons["staff-handover-confirm"].waitForExistence(timeout: 5)); attach("Staff issue evidence and review") - tap(app.buttons["staff-handover-confirm"], in: app) + let confirm = app.buttons["staff-handover-confirm"] + XCTAssertTrue(confirm.waitForExistence(timeout: 5)); XCTAssertFalse(confirm.isEnabled) + let startCount = app.buttons.matching(NSPredicate(format: "label == %@", "Start count for Precision toolkit, Sample probe")) + XCTAssertEqual(startCount.count, 1, "The sample kit has one component that must be explicitly counted.") + tap(startCount.firstMatch, in: app) + let present = app.steppers.matching(NSPredicate(format: "label == %@", "Present Precision toolkit, Sample probe")).firstMatch + XCTAssertTrue(present.waitForExistence(timeout: 5)); XCTAssertEqual(present.value as? String, "0 of 2") + XCTAssertTrue(confirm.isEnabled); attach("Staff issue evidence and review") + tap(confirm, in: app) XCTAssertFalse(app.descendants(matching: .any)["staff-handover-recorded"].firstMatch.exists) recordHandover(in: app) XCTAssertTrue(app.descendants(matching: .any)["staff-handover-recorded"].firstMatch.waitForExistence(timeout: 10)); attach("Staff handover recorded") diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 78edf3c..a0e8635 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,8 @@ Initial native evidence: [`eaa400d` CI39006807733](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/39006807733) passed all 215 native state/API cases and the 22 earlier UI journeys on both phone and tablet. The two new staff journeys hung while opening their destinations, so this phase was not accepted and the unsigned physical-device Release step was skipped. Destination construction registered weak child-cleanup callbacks on an observable array. That bookkeeping now avoids view observation; a focused actual-child construction regression checks that registration does not invalidate the parent view while scope invalidation still clears both children. The corrected 219-native/24-UI complete gate remains pending. +The next checkpoint, [`0c5c38b` CI39009445400](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/39009445400), passed all 219 native cases and the 22 earlier UI journeys on both devices. Both staff destinations now opened without the previous observation hang. The new journeys found later accessibility/test issues: a parent card identifier replaced the attendance button identifier, and the handover test selected no kit components because it queried a shortened label. Remove the unused parent identifier; explicitly select the one sample component and verify its stepper value and disabled-to-enabled confirmation. Wait for departing Profile/scanner content before querying the next screen. Shared scrolling, native confirmations and all post-mutation assertions remain intact. The complete 219-native/24-UI/Release gate remains pending; neither failing checkpoint is phase acceptance. + Add the generated request queues, request decisions, QR resolution, container/kit preview, issue, return, direct handout, booking decisions, event attendance and evidence operations. Staff tools remain closed in production until a fresh native staff authority and the required makerspace actions From 53843cbd4895bef6768bbd520789f6a3d5420677 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:14:37 +0530 Subject: [PATCH 39/98] test(a6): match native controls and scroll visible review content Preserve complete review-button visibility while separating drag margins from the actual viewport. Use a native scroll alternate only after a repeated offscreen review-button frame within the same bounded search. Match the composed native kit count label and retain all handover postconditions. Co-Authored-By: Codex --- .../MemberActionsFlowTests.swift | 29 +++++++++++++++---- SpaceWorksUITests/NativeUIActions.swift | 14 +++++---- SpaceWorksUITests/StaffFlowTests.swift | 5 +++- docs/CHANGELOG.md | 2 ++ 4 files changed, 39 insertions(+), 11 deletions(-) diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index 960c126..891a735 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -10,6 +10,7 @@ final class MemberActionsFlowTests: XCTestCase { var reviewDown = false var previousReviewFrame: CGRect? var capturedReviewStall = false + var capturedNativeAlternate = false for attempt in 0..<24 { let scroll = containingScroll(element, app) if element.exists { @@ -18,6 +19,7 @@ final class MemberActionsFlowTests: XCTestCase { let frame = element.frame let viewport = visibleContent(scroll, app) let visible = frame.intersection(viewport) + let reviewStalled = scroll.identifier == "request-review-scroll" && previousReviewFrame == frame if scroll.identifier == "request-review-scroll" { if !capturedReviewStall, previousReviewFrame == frame { capture("Review scroll stalled before " + element.identifier) @@ -32,9 +34,13 @@ final class MemberActionsFlowTests: XCTestCase { !visible.isNull, visible.width > 4, visible.height > 4 { activate(element); return } // A fitting review button must reveal its entire label before activation. // Its midpoint can be hittable while the second line is below the screen. - let fullReviewButton = scroll.identifier == "request-review-scroll" && element.elementType == .button - && frame.height > 0 && frame.height <= viewport.height - 4 - let positioned = fullReviewButton ? viewport.insetBy(dx: 2, dy: 2).contains(frame) + let isReviewButton = scroll.identifier == "request-review-scroll" && element.elementType == .button + let reviewViewport = isReviewButton ? visibleContent(scroll, app, gestureInset: 0) : viewport + let fullReviewButton = isReviewButton && frame.height > 0 && frame.height <= reviewViewport.height - 4 + // Gesture margins keep drags away from chrome; they are not clipping. + // Check the complete review control against the actual bar-adjusted + // viewport, with a 2pt safety margin, rather than its 8pt drag inset. + let positioned = fullReviewButton ? reviewViewport.insetBy(dx: 2, dy: 2).contains(frame) : viewport.insetBy(dx: 2, dy: 2).contains(CGPoint(x: frame.midX, y: frame.midY)) if element.isHittable, !visible.isNull, visible.width > 4, visible.height > 4, positioned { // Use the native activation point. A wide Toggle's label centre is not @@ -50,6 +56,19 @@ final class MemberActionsFlowTests: XCTestCase { // Sweep consistently in each direction; activation still requires a real // visible, hittable target. New screens normally start near their top. if scroll.identifier == "request-review-scroll" { + // An unchanged far-offscreen target demonstrates a no-op coordinate + // pan. Exercise a native scroll gesture within this same search budget. + // Near the target, retain the bounded drag and whole-button checks. + if isReviewButton, reviewStalled, frame.minY.isFinite, frame.height > 0, frame.minY >= reviewViewport.maxY { + let captureAlternate = !capturedNativeAlternate + capturedNativeAlternate = true + if captureAlternate { capture("Native review scroll alternate before") } + print("Native review swipeUp alternate, attempt \(attempt), target before \(frame)") + scroll.swipeUp(velocity: .slow) + if element.exists { print("Native review swipeUp target after \(element.frame)") } + if captureAlternate { capture("Native review scroll alternate after") } + continue + } // Native geometry proves this target remains in the same modal. // Some drags can make no progress; do not reverse a resumed // search merely because half the gesture budget elapsed. @@ -86,7 +105,7 @@ final class MemberActionsFlowTests: XCTestCase { ?? scrolls.first ?? app.scrollViews.firstMatch } private func area(_ rect: CGRect) -> CGFloat { rect.isNull ? .greatestFiniteMagnitude : rect.width * rect.height } - private func visibleContent(_ scroll: XCUIElement, _ app: XCUIApplication) -> CGRect { + private func visibleContent(_ scroll: XCUIElement, _ app: XCUIApplication, gestureInset: CGFloat = 8) -> CGRect { var viewport = scroll.exists ? scroll.frame.intersection(app.frame) : app.frame // SwiftUI's accessibility ScrollView frame includes content UNDER native bars. // A target can be marked hittable there while its coordinate activates another tab. @@ -103,7 +122,7 @@ final class MemberActionsFlowTests: XCTestCase { let frame = bar.frame if frame.intersects(viewport), frame.midY > viewport.minY { viewport.size.height = max(0, min(viewport.maxY, frame.minY) - viewport.minY) } } - return viewport.insetBy(dx: 8, dy: 8) + return viewport.insetBy(dx: gestureInset, dy: gestureInset) } private func drag(_ scroll: XCUIElement, _ app: XCUIApplication, target: XCUIElement, down: Bool) { let viewport = visibleContent(scroll, app) diff --git a/SpaceWorksUITests/NativeUIActions.swift b/SpaceWorksUITests/NativeUIActions.swift index c671216..c6c8223 100644 --- a/SpaceWorksUITests/NativeUIActions.swift +++ b/SpaceWorksUITests/NativeUIActions.swift @@ -19,9 +19,13 @@ enum NativeUIActions { !visible.isNull, visible.width > 4, visible.height > 4 { element.tap(); return } // A fitting review button must reveal its entire label before activation. // Its midpoint can be hittable while the second line is below the screen. - let fullReviewButton = scroll.identifier == "request-review-scroll" && element.elementType == .button - && frame.height > 0 && frame.height <= viewport.height - 4 - let positioned = fullReviewButton ? viewport.insetBy(dx: 2, dy: 2).contains(frame) + let isReviewButton = scroll.identifier == "request-review-scroll" && element.elementType == .button + let reviewViewport = isReviewButton ? visibleContent(scroll, app, gestureInset: 0) : viewport + let fullReviewButton = isReviewButton && frame.height > 0 && frame.height <= reviewViewport.height - 4 + // Gesture margins keep drags away from chrome; they are not clipping. + // Check the complete review control against the actual bar-adjusted + // viewport, with a 2pt safety margin, rather than its 8pt drag inset. + let positioned = fullReviewButton ? reviewViewport.insetBy(dx: 2, dy: 2).contains(frame) : viewport.insetBy(dx: 2, dy: 2).contains(CGPoint(x: frame.midX, y: frame.midY)) if element.isHittable, !visible.isNull, visible.width > 4, visible.height > 4, positioned { // Use the native activation point. A wide Toggle's label centre is not @@ -71,7 +75,7 @@ enum NativeUIActions { ?? scrolls.first ?? app.scrollViews.firstMatch } private static func area(_ rect: CGRect) -> CGFloat { rect.isNull ? .greatestFiniteMagnitude : rect.width * rect.height } - private static func visibleContent(_ scroll: XCUIElement, _ app: XCUIApplication) -> CGRect { + private static func visibleContent(_ scroll: XCUIElement, _ app: XCUIApplication, gestureInset: CGFloat = 8) -> CGRect { var viewport = scroll.exists ? scroll.frame.intersection(app.frame) : app.frame // SwiftUI's accessibility ScrollView frame includes content UNDER native bars. // A target can be marked hittable there while its coordinate activates another tab. @@ -88,7 +92,7 @@ enum NativeUIActions { let frame = bar.frame if frame.intersects(viewport), frame.midY > viewport.minY { viewport.size.height = max(0, min(viewport.maxY, frame.minY) - viewport.minY) } } - return viewport.insetBy(dx: 8, dy: 8) + return viewport.insetBy(dx: gestureInset, dy: gestureInset) } private static func drag(_ scroll: XCUIElement, _ app: XCUIApplication, target: XCUIElement, down: Bool) { let viewport = visibleContent(scroll, app) diff --git a/SpaceWorksUITests/StaffFlowTests.swift b/SpaceWorksUITests/StaffFlowTests.swift index b819a5a..46a1478 100644 --- a/SpaceWorksUITests/StaffFlowTests.swift +++ b/SpaceWorksUITests/StaffFlowTests.swift @@ -66,7 +66,10 @@ final class StaffFlowTests: XCTestCase { let startCount = app.buttons.matching(NSPredicate(format: "label == %@", "Start count for Precision toolkit, Sample probe")) XCTAssertEqual(startCount.count, 1, "The sample kit has one component that must be explicitly counted.") tap(startCount.firstMatch, in: app) - let present = app.steppers.matching(NSPredicate(format: "label == %@", "Present Precision toolkit, Sample probe")).firstMatch + // SwiftUI composes the visible count title with its semantic label. + let presentCounts = app.steppers.matching(NSPredicate(format: "label ENDSWITH %@", "Present Precision toolkit, Sample probe")) + XCTAssertEqual(presentCounts.count, 1, "The sample kit exposes one counted component.") + let present = presentCounts.firstMatch XCTAssertTrue(present.waitForExistence(timeout: 5)); XCTAssertEqual(present.value as? String, "0 of 2") XCTAssertTrue(confirm.isEnabled); attach("Staff issue evidence and review") tap(confirm, in: app) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a0e8635..acc93ac 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,8 @@ Initial native evidence: [`eaa400d` CI39006807733](https://github.com/SpaceWorks The next checkpoint, [`0c5c38b` CI39009445400](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/39009445400), passed all 219 native cases and the 22 earlier UI journeys on both devices. Both staff destinations now opened without the previous observation hang. The new journeys found later accessibility/test issues: a parent card identifier replaced the attendance button identifier, and the handover test selected no kit components because it queried a shortened label. Remove the unused parent identifier; explicitly select the one sample component and verify its stepper value and disabled-to-enabled confirmation. Wait for departing Profile/scanner content before querying the next screen. Shared scrolling, native confirmations and all post-mutation assertions remain intact. The complete 219-native/24-UI/Release gate remains pending; neither failing checkpoint is phase acceptance. +The following [`36fd853` checkpoint](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/39012325754) again passed all 219 native cases on both devices. Event attendance passed and its resolved-person and recorded screens were reviewed. The guided issue test reached an enabled confirmation but queried the stepper with an exact label; SwiftUI combines its visible count title and semantic label. Match the unique semantic suffix and retain the explicit component count, value and confirmation checks. A previous large-text request journey also failed: on tablet the whole primary button was visible, but the test confused its 8pt gesture margin with actual clipping. Whole-button activation now uses the actual native-bar-adjusted viewport with a 2pt margin; gesture margins stay unchanged. On phone repeated coordinate pans made no progress while the target remained offscreen. That one helper can use a native scroll gesture after an unchanged offscreen frame, within the same 24-attempt bound, retaining full visibility, native hittability and receipt assertions. This is a test gesture correction, not a claimed application cause. The complete gate remains pending; no cases are skipped. + Add the generated request queues, request decisions, QR resolution, container/kit preview, issue, return, direct handout, booking decisions, event attendance and evidence operations. Staff tools remain closed in production until a fresh native staff authority and the required makerspace actions From d05dcfc477dae19bd2acd78201f783c9d27cfd61 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:57:57 +0530 Subject: [PATCH 40/98] feat(a7): complete account security and localization-ready flows Add global account security, devices, phone linking and password management alongside capability-gated member sessions, membership and exports. Include isolated whole-account deletion and email-verification previews, accepted-session ownership guards, timer-race regressions, English localization resources and complete account UI journeys. Record the accepted A6 phone/tablet native gate and independent staff/receipt visual evidence. A7 native compilation, runtime journeys and unsigned Release validation remain pending. Co-Authored-By: Codex --- Fixtures/Account/archive.json | 3 + Fixtures/Account/challenge.json | 6 + Fixtures/Account/export.json | 12 + Fixtures/Account/grants.json | 22 + Fixtures/Account/membership.json | 4 + Fixtures/Account/mfa.json | 5 + Fixtures/Account/provider.json | 5 + Fixtures/Account/recovery.json | 7 + Fixtures/Account/sessions.json | 23 + Packages/SpaceWorksAPI/Package.swift | 2 + .../SpaceWorksAPI/APILocalization.swift | 9 + .../SpaceWorksAPI/AccountOperations.swift | 87 + .../Sources/SpaceWorksAPI/Networking.swift | 73 +- .../SpaceWorksAPI/PaymentOperations.swift | 4 +- .../SpaceWorksAPI/PushOperations.swift | 2 +- .../Resources/en.lproj/Localizable.strings | 19 + .../openapi-generator-config.yaml | 22 + SpaceWorks/AccountAccessState.swift | 37 +- SpaceWorks/AccountAccessView.swift | 69 +- SpaceWorks/AccountDemo.swift | 124 + SpaceWorks/AccountMakerspaceLanding.swift | 14 + SpaceWorks/AccountPasswordView.swift | 33 + SpaceWorks/AccountRepository.swift | 218 + SpaceWorks/AccountState.swift | 378 + SpaceWorks/AccountViews.swift | 344 + SpaceWorks/AuthState.swift | 161 +- SpaceWorks/AuthView.swift | 26 +- SpaceWorks/BookingState.swift | 2 +- SpaceWorks/BookingViews.swift | 6 +- SpaceWorks/DemoAccountLandingView.swift | 28 + SpaceWorks/DemoAccountScope.swift | 76 + SpaceWorks/DemoDeletion.swift | 150 + SpaceWorks/DemoEmailVerification.swift | 34 + SpaceWorks/DemoMemberActionsRepository.swift | 33 +- SpaceWorks/DemoMemberRepository.swift | 4 +- SpaceWorks/DemoMode.swift | 131 +- SpaceWorks/DemoNotificationsView.swift | 4 +- SpaceWorks/DemoProviderState.swift | 10 +- SpaceWorks/EventRegistrationState.swift | 14 +- SpaceWorks/EventViews.swift | 14 +- SpaceWorks/IntroductionView.swift | 8 +- SpaceWorks/MemberActionViews.swift | 2 +- SpaceWorks/MemberActionsState.swift | 8 +- SpaceWorks/MemberCapabilities.swift | 3 +- SpaceWorks/MemberCustomForm.swift | 18 +- SpaceWorks/MemberNavigation.swift | 2 +- SpaceWorks/MemberRoutes.swift | 13 + SpaceWorks/MemberSettingsState.swift | 10 +- SpaceWorks/MemberSettingsViews.swift | 14 +- SpaceWorks/MemberState.swift | 2 +- SpaceWorks/MemberTicketState.swift | 2 +- SpaceWorks/MemberViews.swift | 16 +- SpaceWorks/MobileLinkDestination.swift | 4 +- SpaceWorks/MobileLinkRouter.swift | 6 +- SpaceWorks/NativeAttestor.swift | 2 +- SpaceWorks/NativeStatusCopy.swift | 53 + SpaceWorks/PasswordEntry.swift | 4 +- SpaceWorks/PaymentsDemo.swift | 4 +- SpaceWorks/PaymentsLinkDestination.swift | 4 +- SpaceWorks/PaymentsState.swift | 6 +- SpaceWorks/PaymentsViews.swift | 2 +- SpaceWorks/Platform/NativeFileTransfer.swift | 12 +- SpaceWorks/Platform/NativeIdentity.swift | 2 +- SpaceWorks/Platform/NativeNotifications.swift | 4 +- SpaceWorks/Platform/NativePrivacyShield.swift | 2 +- SpaceWorks/Platform/NativeScanner.swift | 8 +- SpaceWorks/ProviderButtons.swift | 2 +- SpaceWorks/PushRegistrationState.swift | 4 +- SpaceWorks/PushRegistrationVault.swift | 2 +- SpaceWorks/Resources/Localizable.xcstrings | 8146 +++++++++++++++++ .../Resources/en.lproj/InfoPlist.strings | 1 + SpaceWorks/RootView.swift | 28 +- SpaceWorks/SpaceWorksApp.swift | 3 +- SpaceWorks/StaffCheckInState.swift | 8 +- SpaceWorks/StaffDemo.swift | 16 +- SpaceWorks/StaffHandoverState.swift | 16 +- SpaceWorks/StaffHandoverView.swift | 20 +- SpaceWorks/StaffRepository.swift | 6 +- SpaceWorks/StaffState.swift | 10 +- SpaceWorks/StaffViews.swift | 18 +- SpaceWorksTests/AccountAuthTests.swift | 65 + SpaceWorksTests/AccountCooldownTests.swift | 94 + SpaceWorksTests/AccountOperationTests.swift | 350 + SpaceWorksTests/AccountTests.swift | 206 + SpaceWorksTests/AuthTests.swift | 29 +- SpaceWorksTests/DemoAccountScopeTests.swift | 55 + SpaceWorksTests/DemoDeletionTests.swift | 42 + .../DemoEmailVerificationTests.swift | 30 + SpaceWorksTests/EmailOwnerTests.swift | 192 + SpaceWorksUITests/AccountFlowTests.swift | 129 + SpaceWorksUITests/SecurityFlowTests.swift | 39 + docs/CHANGELOG.md | 62 + docs/LOCALIZATION.md | 13 + project.yml | 2 + scripts/build_account_fixtures.py | 38 + scripts/export_localization_catalog.py | 57 + scripts/generate.py | 4 + scripts/generate_account_fixtures.py | 16 + scripts/tests/test_account_fixtures.py | 21 + 99 files changed, 11833 insertions(+), 317 deletions(-) create mode 100644 Fixtures/Account/archive.json create mode 100644 Fixtures/Account/challenge.json create mode 100644 Fixtures/Account/export.json create mode 100644 Fixtures/Account/grants.json create mode 100644 Fixtures/Account/membership.json create mode 100644 Fixtures/Account/mfa.json create mode 100644 Fixtures/Account/provider.json create mode 100644 Fixtures/Account/recovery.json create mode 100644 Fixtures/Account/sessions.json create mode 100644 Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/APILocalization.swift create mode 100644 Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/AccountOperations.swift create mode 100644 Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Resources/en.lproj/Localizable.strings create mode 100644 SpaceWorks/AccountDemo.swift create mode 100644 SpaceWorks/AccountMakerspaceLanding.swift create mode 100644 SpaceWorks/AccountPasswordView.swift create mode 100644 SpaceWorks/AccountRepository.swift create mode 100644 SpaceWorks/AccountState.swift create mode 100644 SpaceWorks/AccountViews.swift create mode 100644 SpaceWorks/DemoAccountLandingView.swift create mode 100644 SpaceWorks/DemoAccountScope.swift create mode 100644 SpaceWorks/DemoDeletion.swift create mode 100644 SpaceWorks/DemoEmailVerification.swift create mode 100644 SpaceWorks/NativeStatusCopy.swift create mode 100644 SpaceWorks/Resources/Localizable.xcstrings create mode 100644 SpaceWorks/Resources/en.lproj/InfoPlist.strings create mode 100644 SpaceWorksTests/AccountAuthTests.swift create mode 100644 SpaceWorksTests/AccountCooldownTests.swift create mode 100644 SpaceWorksTests/AccountOperationTests.swift create mode 100644 SpaceWorksTests/AccountTests.swift create mode 100644 SpaceWorksTests/DemoAccountScopeTests.swift create mode 100644 SpaceWorksTests/DemoDeletionTests.swift create mode 100644 SpaceWorksTests/DemoEmailVerificationTests.swift create mode 100644 SpaceWorksTests/EmailOwnerTests.swift create mode 100644 SpaceWorksUITests/AccountFlowTests.swift create mode 100644 SpaceWorksUITests/SecurityFlowTests.swift create mode 100644 docs/LOCALIZATION.md create mode 100644 scripts/build_account_fixtures.py create mode 100644 scripts/export_localization_catalog.py create mode 100644 scripts/generate_account_fixtures.py create mode 100644 scripts/tests/test_account_fixtures.py diff --git a/Fixtures/Account/archive.json b/Fixtures/Account/archive.json new file mode 100644 index 0000000..abb1d99 --- /dev/null +++ b/Fixtures/Account/archive.json @@ -0,0 +1,3 @@ +{ + "base64": "UEsDBBQAAAAAAABIUF2EsZAMUAAAAFAAAAAKAAAAUkVBRE1FLnR4dFNQQUNFIFdPUktTIERFTU8gRVhQT1JUClNhbXBsZSBkYXRhIG9ubHkuIE5vIHBlcnNvbmFsIGFjY291bnQgZGF0YSBpcyBpbmNsdWRlZC4KUEsBAhQAFAAAAAAAAEhQXYSxkAxQAAAAUAAAAAoAAAAAAAAAAAAAAIABAAAAAFJFQURNRS50eHRQSwUGAAAAAAEAAQA4AAAAeAAAAAAA" +} diff --git a/Fixtures/Account/challenge.json b/Fixtures/Account/challenge.json new file mode 100644 index 0000000..ad94436 --- /dev/null +++ b/Fixtures/Account/challenge.json @@ -0,0 +1,6 @@ +{ + "challenge": "demo-account-challenge", + "enrollment_required": true, + "expires_in": 300, + "mfa_required": true +} diff --git a/Fixtures/Account/export.json b/Fixtures/Account/export.json new file mode 100644 index 0000000..dce943e --- /dev/null +++ b/Fixtures/Account/export.json @@ -0,0 +1,12 @@ +{ + "completed_at": null, + "created_at": "2026-10-16T09:00:00Z", + "expires_at": "2099-01-01T00:00:00Z", + "failure_code": "", + "failure_detail": "", + "id": "84000000-0000-4000-8000-000000000001", + "manifest": { + "demo": true + }, + "status": "available" +} diff --git a/Fixtures/Account/grants.json b/Fixtures/Account/grants.json new file mode 100644 index 0000000..c7ca607 --- /dev/null +++ b/Fixtures/Account/grants.json @@ -0,0 +1,22 @@ +[ + { + "app_id": "org.spaceworks.app", + "attested_at": "2026-10-16T09:00:00Z", + "created_at": "2026-10-16T09:00:00Z", + "environment": "development", + "id": "83000000-0000-4000-8000-000000000001", + "last_used_at": "2026-10-16T09:00:00Z", + "platform": "apple", + "status": "active" + }, + { + "app_id": "org.spaceworks.app", + "attested_at": "2026-10-16T09:00:00Z", + "created_at": "2026-10-16T09:00:00Z", + "environment": "development", + "id": "83000000-0000-4000-8000-000000000002", + "last_used_at": "2026-10-16T09:00:00Z", + "platform": "android", + "status": "active" + } +] diff --git a/Fixtures/Account/membership.json b/Fixtures/Account/membership.json new file mode 100644 index 0000000..d5cc675 --- /dev/null +++ b/Fixtures/Account/membership.json @@ -0,0 +1,4 @@ +{ + "renewal_cancelled_at": null, + "status": "active" +} diff --git a/Fixtures/Account/mfa.json b/Fixtures/Account/mfa.json new file mode 100644 index 0000000..454c9ec --- /dev/null +++ b/Fixtures/Account/mfa.json @@ -0,0 +1,5 @@ +{ + "available": true, + "enabled": true, + "required": false +} diff --git a/Fixtures/Account/provider.json b/Fixtures/Account/provider.json new file mode 100644 index 0000000..798bae8 --- /dev/null +++ b/Fixtures/Account/provider.json @@ -0,0 +1,5 @@ +{ + "created_at": "2026-10-16T09:00:00Z", + "provider": "google", + "updated_at": "2026-10-16T09:00:00Z" +} diff --git a/Fixtures/Account/recovery.json b/Fixtures/Account/recovery.json new file mode 100644 index 0000000..c5ebc1d --- /dev/null +++ b/Fixtures/Account/recovery.json @@ -0,0 +1,7 @@ +{ + "recovery_codes": [ + "demo-recovery-01", + "demo-recovery-02", + "demo-recovery-03" + ] +} diff --git a/Fixtures/Account/sessions.json b/Fixtures/Account/sessions.json new file mode 100644 index 0000000..0b2da84 --- /dev/null +++ b/Fixtures/Account/sessions.json @@ -0,0 +1,23 @@ +{ + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "created_at": "2026-10-16T09:00:00Z", + "current": true, + "expires_at": null, + "id": "device:83000000-0000-4000-8000-000000000001", + "kind": "device", + "last_used_at": null + }, + { + "created_at": "2026-10-16T09:00:00Z", + "current": false, + "expires_at": null, + "id": "web:sample-other-session", + "kind": "web", + "last_used_at": null + } + ] +} diff --git a/Packages/SpaceWorksAPI/Package.swift b/Packages/SpaceWorksAPI/Package.swift index fdc5158..6f1c12b 100644 --- a/Packages/SpaceWorksAPI/Package.swift +++ b/Packages/SpaceWorksAPI/Package.swift @@ -3,6 +3,7 @@ import PackageDescription let package = Package( name: "SpaceWorksAPI", + defaultLocalization: "en", platforms: [.iOS(.v17), .macOS(.v13)], products: [.library(name: "SpaceWorksAPI", type: .dynamic, targets: ["SpaceWorksAPI"])], dependencies: [ @@ -18,6 +19,7 @@ let package = Package( .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"), .product(name: "HTTPTypes", package: "swift-http-types") ], + resources: [.process("Resources")], plugins: [.plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator")] )] ) diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/APILocalization.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/APILocalization.swift new file mode 100644 index 0000000..e7faf0c --- /dev/null +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/APILocalization.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Only compile-time application copy is accepted. Remote details are never lookup keys. +enum APILocalCopy { + static func text(_ key: StaticString) -> String { + let source = String(describing: key) + return NSLocalizedString(source, bundle: .module, value: source, comment: "Application fallback error") + } +} diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/AccountOperations.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/AccountOperations.swift new file mode 100644 index 0000000..8f37e77 --- /dev/null +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/AccountOperations.swift @@ -0,0 +1,87 @@ +import Foundation +import OpenAPIRuntime + +public enum AccountOperationFailure { + /// Generated ClientError wraps task/scope cancellation; do not turn it into account revocation. + public static func isCancellation(_ error: Error) -> Bool { + if error is CancellationError || (error as? URLError)?.code == .cancelled { return true } + if let client = error as? ClientError { return isCancellation(client.underlyingError) } + return false + } +} + +extension SpaceWorksService { + public func accountGrants() async throws -> [Components.Schemas.DeviceGrant] { + try await client.api_v1_auth_device_grants_list().ok.body.json + } + public func accountRevokeGrant(id: String) async throws { + _ = try await client.api_v1_auth_device_grants_destroy(.init(path: .init(grant_id: id))).noContent + } + /// The pinned schema declares no page query, despite returning a paginated envelope. + public func accountSessions(makerspaceID: Int) async throws -> Components.Schemas.AccountSessionPage { + try await client.api_v1_member_makerspaces_account_sessions_retrieve(.init(path: .init(makerspace_id: makerspaceID))).ok.body.json + } + public func accountRevokeSession(makerspaceID: Int, id: String) async throws { + _ = try await client.api_v1_member_makerspaces_account_sessions_destroy(.init(path: .init(makerspace_id: makerspaceID, session_id: id))).noContent + } + public func accountMembership(makerspaceID: Int) async throws -> Components.Schemas.MembershipAccount { + try await client.api_v1_member_makerspaces_account_membership_retrieve(.init(path: .init(makerspace_id: makerspaceID))).ok.body.json + } + public func accountLeave(makerspaceID: Int, reason: String) async throws -> Components.Schemas.MembershipAccount { + try await client.api_v1_member_makerspaces_account_leave_create(.init(path: .init(makerspace_id: makerspaceID), body: .json(.init(reason: reason)))).ok.body.json + } + public func accountCancelRenewal(makerspaceID: Int, reason: String) async throws -> Components.Schemas.MembershipAccount { + try await client.api_v1_member_makerspaces_account_cancel_create(.init(path: .init(makerspace_id: makerspaceID), body: .json(.init(reason: reason)))).ok.body.json + } + /// The pinned schema declares no page query, despite returning a paginated envelope. + public func accountExports(makerspaceID: Int) async throws -> Components.Schemas.SubjectExportList { + try await client.api_v1_member_makerspaces_account_exports_retrieve(.init(path: .init(makerspace_id: makerspaceID))).ok.body.json + } + public func accountRequestExport(makerspaceID: Int) async throws -> Components.Schemas.SubjectExport { + try await client.api_v1_member_makerspaces_account_exports_create(.init(path: .init(makerspace_id: makerspaceID), body: .json(.init()))).accepted.body.json + } + public func accountExport(makerspaceID: Int, id: String) async throws -> Components.Schemas.SubjectExport { + try await client.api_v1_member_makerspaces_account_exports_retrieve_2(.init(path: .init(job_id: id, makerspace_id: makerspaceID))).ok.body.json + } + public func accountExportDownload(makerspaceID: Int, id: String) async throws -> Components.Schemas.DataExportDownloadUrl { + try await client.api_v1_member_makerspaces_account_exports_download_create(.init(path: .init(job_id: id, makerspace_id: makerspaceID))).ok.body.json + } + /// Consume a fresh, validated one-use grant through its documented public API operation. + /// URLSessionTransport currently buffers first; this is an honest post-buffer collection cap. + public func accountExportArchive(id: String, token: String) async throws -> Data { + let response = try await publicClient.api_v1_data_exports_download_retrieve(.init(path: .init(job_id: id, token: token))) + let body: HTTPBody + switch try response.ok.body { + case .application_zip(let value), .binary(let value): body = value + } + return try await Data(collecting: body, upTo: 25 * 1024 * 1024) + } + public func accountMFA() async throws -> Components.Schemas.MFAStatus { + try await client.api_v1_auth_mfa_retrieve().ok.body.json + } + /// Kept as a generated operation seam. Current backend refuses this route for device grants. + public func accountStartMFA(password: String) async throws -> Components.Schemas.MFAChallengeResponse { + try await client.api_v1_auth_mfa_create(.init(body: .json(.init(password: password)))).ok.body.json + } + public func accountDisableMFA(password: String) async throws -> Components.Schemas.MFAStatus { + try await client.api_v1_auth_mfa_disable_create(.init(body: .json(.init(password: password)))).ok.body.json + } + public func accountRegenerateRecovery(password: String) async throws -> Components.Schemas.MFARecovery { + try await client.api_v1_auth_mfa_recovery_create(.init(body: .json(.init(password: password)))).ok.body.json + } + public func accountStartPhone(phone: String) async throws -> Components.Schemas.PhoneStartResponse { + try await client.api_v1_auth_phone_link_start_create(.init(body: .json(.init(phone: phone)))).ok.body.json + } + public func accountConfirmPhone(phone: String, code: String) async throws -> Components.Schemas.PhoneStatus { + try await client.api_v1_auth_phone_link_confirm_create(.init(body: .json(.init(phone: phone, code: code)))).ok.body.json + } + public func accountUnlinkPhone() async throws -> Components.Schemas.PhoneStatus { + try await client.api_v1_auth_phone_destroy().ok.body.json + } + public func accountProviders() async throws -> [Components.Schemas.SocialIdentity] { + try await client.api_v1_auth_social_providers_list().ok.body.json + } + public func accountUnlinkProvider(_ provider: String) async throws { + _ = try await client.api_v1_auth_social_providers_destroy(.init(path: .init(provider: provider))).noContent + } +} diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift index b1b55df..873030e 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift @@ -17,7 +17,7 @@ public struct ServerOrigin: Sendable, Equatable { parts.query == nil, parts.fragment == nil, ["", "/"].contains(parts.path), parts.port == nil || (1...65535).contains(parts.port!), parts.scheme == "https" || (localHTTP && parts.scheme == "http" && ["localhost", "127.0.0.1", "::1"].contains(host)) - else { throw APIError(status: 0, code: "invalid_server_url", detail: "Enter a valid HTTPS server URL.") } + else { throw APIError(status: 0, code: "invalid_server_url", detail: APILocalCopy.text("Enter a valid HTTPS server URL.")) } parts.path = "" guard let url = parts.url else { throw URLError(.badURL) } self.url = url @@ -33,7 +33,7 @@ public struct ServerOrigin: Sendable, Equatable { parts.user == nil, parts.password == nil, parts.fragment == nil, candidate.path.hasPrefix("/api/"), !(parts.percentEncodedPath.removingPercentEncoding ?? "").split(separator: "/").contains(where: { $0 == "." || $0 == ".." }) - else { throw APIError(status: 0, code: "invalid_next_page", detail: "The server returned an invalid next page.") } + else { throw APIError(status: 0, code: "invalid_next_page", detail: APILocalCopy.text("The server returned an invalid next page.")) } return candidate } } @@ -48,9 +48,9 @@ public struct APIError: Error, LocalizedError, Sendable, Equatable { } public var errorDescription: String? { switch code { - case "device_superadmin_not_supported": return "Use the web console" - case "payment_rail_conflict": return "Finish this payment on the web." - case "payments_unavailable": return "Online payment is unavailable. Try again later or pay at the desk." + case "device_superadmin_not_supported": return APILocalCopy.text("Use the web console") + case "payment_rail_conflict": return APILocalCopy.text("Finish this payment on the web.") + case "payments_unavailable": return APILocalCopy.text("Online payment is unavailable. Try again later or pay at the desk.") default: return detail } } @@ -64,7 +64,7 @@ public struct APIError: Error, LocalizedError, Sendable, Equatable { public static func display(_ error: Error) -> String { if let error = error as? APIError { return error.errorDescription ?? error.detail } if let error = error as? ClientError { return display(error.underlyingError) } - return "Couldn't connect. Try again." + return APILocalCopy.text("Couldn't connect. Try again.") } public static func retryDeadline(_ error: Error) -> Date? { if let error = error as? APIError { return error.retryAfter } @@ -121,7 +121,7 @@ public actor RefreshCoordinator { } public func recover(rejected snapshot: CredentialSnapshot) async throws -> TokenPair { guard generation == snapshot.epoch, let rejectedAccess = snapshot.pair?.access else { throw CancellationError() } - guard let current = pair else { throw APIError(status: 401, code: "signed_out", detail: "Sign in to continue.") } + guard let current = pair else { throw APIError(status: 401, code: "signed_out", detail: APILocalCopy.text("Sign in to continue.")) } if current.access != rejectedAccess { return current } if let flight { return try await flight.value } let rotate = self.rotate @@ -161,7 +161,7 @@ public actor MakerspaceContext { /// A1 supplies active membership IDs from the generated /auth/me response. public func select(_ id: Int?, activeMemberships: Set) throws { guard id == nil || activeMemberships.contains(id!) else { - throw APIError(status: 403, code: "inactive_membership", detail: "Select an active makerspace membership.") + throw APIError(status: 403, code: "inactive_membership", detail: APILocalCopy.text("Select an active makerspace membership.")) } activeDirectMemberships = activeMemberships selected = id @@ -206,14 +206,14 @@ private func checked(_ result: (HTTPResponse, HTTPBody?)) async throws -> (HTTPR if response.status.code == 429, retry == nil { retry = Date().addingTimeInterval(60) } let fallback: String switch response.status.code { - case 400: fallback = "Check the details and try again." - case 401: fallback = "Please sign in again." - case 403: fallback = "You do not have permission to do this." - case 404: fallback = "This item wasn't found." - case 409: fallback = "This changed. Refresh and try again." - case 429: fallback = "Too many requests. Try again later." - case 503: fallback = "The server is unavailable. Try again later." - default: fallback = "Couldn't connect. Try again." + case 400: fallback = APILocalCopy.text("Check the details and try again.") + case 401: fallback = APILocalCopy.text("Please sign in again.") + case 403: fallback = APILocalCopy.text("You do not have permission to do this.") + case 404: fallback = APILocalCopy.text("This item wasn't found.") + case 409: fallback = APILocalCopy.text("This changed. Refresh and try again.") + case 429: fallback = APILocalCopy.text("Too many requests. Try again later.") + case 503: fallback = APILocalCopy.text("The server is unavailable. Try again later.") + default: fallback = APILocalCopy.text("Couldn't connect. Try again.") } let message: String if let detail, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { message = detail } @@ -240,6 +240,7 @@ private enum DeviceRequestScope { @TaskLocal static var makerspace: Int? @TaskLocal static var credentials: CredentialSnapshot? @TaskLocal static var pushMakerspace: Int? + @TaskLocal static var globalAccount = false } public struct SessionMiddleware: ClientMiddleware { @@ -260,7 +261,12 @@ public struct SessionMiddleware: ClientMiddleware { guard ["api_v1_integrations_push_devices_create", "api_v1_integrations_push_devices_destroy"].contains(operationID), await makerspace.authorizesPush(push) else { throw CancellationError() } } - request.headerFields[HTTPField.Name("X-Makerspace-Id")!] = (DeviceRequestScope.pushMakerspace ?? DeviceRequestScope.makerspace ?? selected).map(String.init) + if DeviceRequestScope.globalAccount { + guard operationID.hasPrefix("api_v1_auth_"), DeviceRequestScope.pushMakerspace == nil else { throw CancellationError() } + request.headerFields[HTTPField.Name("X-Makerspace-Id")!] = nil + } else { + request.headerFields[HTTPField.Name("X-Makerspace-Id")!] = (DeviceRequestScope.pushMakerspace ?? DeviceRequestScope.makerspace ?? selected).map(String.init) + } let result = try await next(request, body, baseURL) if result.0.status.code == 401, pair != nil { let updated = try await refresh.recover(rejected: credentials) @@ -297,7 +303,7 @@ public enum DeviceSession { public final class SpaceWorksService: Sendable { let client: Client - private let publicClient: Client + let publicClient: Client public let refresh: RefreshCoordinator public let makerspace: MakerspaceContext public init(origin: ServerOrigin, session: URLSession = DeviceSession.make(), initialTokens: TokenPair? = nil, @@ -327,7 +333,7 @@ public final class SpaceWorksService: Sendable { if let expectedCredentials { guard await refresh.isSameInstallation(expectedCredentials) else { throw CancellationError() } guard await refresh.isCurrent(expectedCredentials) else { - throw APIError(status: 401, code: "signed_out", detail: "Please sign in again.") + throw APIError(status: 401, code: "signed_out", detail: APILocalCopy.text("Please sign in again.")) } } let credentials: CredentialSnapshot @@ -335,7 +341,26 @@ public final class SpaceWorksService: Sendable { else if let expectedCredentials { credentials = expectedCredentials } else { credentials = await refresh.snapshot() } return try await DeviceRequestScope.$credentials.withValue(credentials) { - try await DeviceRequestScope.$makerspace.withValue(id, operation: operation) + try await DeviceRequestScope.$globalAccount.withValue(false) { + try await DeviceRequestScope.$makerspace.withValue(id, operation: operation) + } + } + } + + /// Account security remains available before joining or selecting a makerspace. This scope + /// carries the device session epoch and permits only generated auth operations, without a maker header. + public func withSession(expectedCredentials: CredentialSnapshot? = nil, operation: @Sendable () async throws -> T) async throws -> T { + guard DeviceRequestScope.pushMakerspace == nil else { throw CancellationError() } + if let expectedCredentials, !(await refresh.isCurrent(expectedCredentials)) { throw CancellationError() } + let credentials: CredentialSnapshot + if let existing = DeviceRequestScope.credentials { credentials = existing } + else if let expectedCredentials { credentials = expectedCredentials } + else { credentials = await refresh.snapshot() } + guard await refresh.isCurrent(credentials) else { throw CancellationError() } + return try await DeviceRequestScope.$credentials.withValue(credentials) { + try await DeviceRequestScope.$globalAccount.withValue(true) { + try await DeviceRequestScope.$makerspace.withValue(nil, operation: operation) + } } } @@ -348,8 +373,12 @@ public final class SpaceWorksService: Sendable { else { credentials = await refresh.snapshot() } guard await refresh.isCurrent(credentials) else { throw CancellationError() } return try await DeviceRequestScope.$credentials.withValue(credentials) { - try await DeviceRequestScope.$pushMakerspace.withValue(id) { - try await DeviceRequestScope.$makerspace.withValue(nil, operation: operation) + // Global account security can end its session while still owning push cleanup. + // This explicit narrower scope must not inherit the auth-only operation flag. + try await DeviceRequestScope.$globalAccount.withValue(false) { + try await DeviceRequestScope.$pushMakerspace.withValue(id) { + try await DeviceRequestScope.$makerspace.withValue(nil, operation: operation) + } } } } diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PaymentOperations.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PaymentOperations.swift index a6922c8..7742958 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PaymentOperations.swift +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PaymentOperations.swift @@ -23,7 +23,7 @@ public enum PaymentDownloadGrant { let items = parts.queryItems, items.count == 1, items[0].name == "token", let token = items[0].value, !token.isEmpty, token.utf8.count <= 4096, !token.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { - throw APIError(status: 0, code: "invalid_document_grant", detail: "This document link is unavailable. Request a new link and try again.") + throw APIError(status: 0, code: "invalid_document_grant", detail: APILocalCopy.text("This document link is unavailable. Request a new link and try again.")) } return token } @@ -46,7 +46,7 @@ extension SpaceWorksService { let grant = try await client.api_v1_member_makerspaces_payment_documents_download_url_retrieve( .init(path: .init(document_id: documentID, makerspace_id: makerspaceID))).ok.body.json guard grant.expires_in > 0 else { - throw APIError(status: 0, code: "expired_document_grant", detail: "This document link expired. Try again to request a new link.") + throw APIError(status: 0, code: "expired_document_grant", detail: APILocalCopy.text("This document link expired. Try again to request a new link.")) } let token = try PaymentDownloadGrant.token(from: grant.url, documentID: documentID, origin: origin) // This operation requires the authenticated generated client even with the signed token. diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PushOperations.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PushOperations.swift index 78b9c5c..46207ff 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PushOperations.swift +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PushOperations.swift @@ -15,7 +15,7 @@ extension SpaceWorksService { switch response { case .ok(let value): return try value.body.json case .created(let value): return try value.body.json - default: throw APIError(status: 0, code: "push_registration_unavailable", detail: "Notifications could not be registered. Your inbox is still available.") + default: throw APIError(status: 0, code: "push_registration_unavailable", detail: APILocalCopy.text("Notifications could not be registered. Your inbox is still available.")) } } public func unregisterNativePush(deviceID: Int) async throws { diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Resources/en.lproj/Localizable.strings b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..61bc414 --- /dev/null +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Resources/en.lproj/Localizable.strings @@ -0,0 +1,19 @@ +// English fallback copy only. Server-provided details remain verbatim. +"Check the details and try again." = "Check the details and try again."; +"Couldn't connect. Try again." = "Couldn't connect. Try again."; +"Enter a valid HTTPS server URL." = "Enter a valid HTTPS server URL."; +"Finish this payment on the web." = "Finish this payment on the web."; +"Notifications could not be registered. Your inbox is still available." = "Notifications could not be registered. Your inbox is still available."; +"Online payment is unavailable. Try again later or pay at the desk." = "Online payment is unavailable. Try again later or pay at the desk."; +"Please sign in again." = "Please sign in again."; +"Select an active makerspace membership." = "Select an active makerspace membership."; +"Sign in to continue." = "Sign in to continue."; +"The server is unavailable. Try again later." = "The server is unavailable. Try again later."; +"The server returned an invalid next page." = "The server returned an invalid next page."; +"This changed. Refresh and try again." = "This changed. Refresh and try again."; +"This document link expired. Try again to request a new link." = "This document link expired. Try again to request a new link."; +"This document link is unavailable. Request a new link and try again." = "This document link is unavailable. Request a new link and try again."; +"This item wasn't found." = "This item wasn't found."; +"Too many requests. Try again later." = "Too many requests. Try again later."; +"Use the web console" = "Use the web console"; +"You do not have permission to do this." = "You do not have permission to do this."; diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml index 3cd7cdb..5203ea6 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml @@ -78,3 +78,25 @@ filter: - api_v1_admin_spaces_bookings_retrieve - api_v1_admin_bookings_approve_create - api_v1_admin_bookings_reject_create + + - api_v1_auth_device_grants_list + - api_v1_auth_device_grants_destroy + - api_v1_member_makerspaces_account_sessions_retrieve + - api_v1_member_makerspaces_account_sessions_destroy + - api_v1_member_makerspaces_account_membership_retrieve + - api_v1_member_makerspaces_account_leave_create + - api_v1_member_makerspaces_account_cancel_create + - api_v1_member_makerspaces_account_exports_retrieve + - api_v1_member_makerspaces_account_exports_create + - api_v1_member_makerspaces_account_exports_retrieve_2 + - api_v1_member_makerspaces_account_exports_download_create + - api_v1_data_exports_download_retrieve + - api_v1_auth_mfa_retrieve + - api_v1_auth_mfa_create + - api_v1_auth_mfa_disable_create + - api_v1_auth_mfa_recovery_create + - api_v1_auth_phone_link_start_create + - api_v1_auth_phone_link_confirm_create + - api_v1_auth_phone_destroy + - api_v1_auth_social_providers_list + - api_v1_auth_social_providers_destroy diff --git a/SpaceWorks/AccountAccessState.swift b/SpaceWorks/AccountAccessState.swift index dce0e43..8afa123 100644 --- a/SpaceWorks/AccountAccessState.swift +++ b/SpaceWorks/AccountAccessState.swift @@ -44,27 +44,28 @@ final class AccountAccessState: Identifiable { private let verify: (String) async throws -> Void private let resendVerification: () async throws -> Void private let now: () -> Date + private let ownerIsCurrent: () -> Bool init(step: Step, now: @escaping () -> Date = Date.init, cooldown: AccountAccessCooldown? = nil, isDemo: Bool = false, create: @escaping (String, String, String, String) async throws -> Void, request: @escaping (String) async throws -> Void, reset: @escaping (String, String, String) async throws -> Void, verify: @escaping (String) async throws -> Void = { _ in }, - resendVerification: @escaping () async throws -> Void = {}) { + resendVerification: @escaping () async throws -> Void = {}, ownerIsCurrent: @escaping () -> Bool = { true }) { self.step = step; self.now = now; self.create = create; self.request = request self.reset = reset; self.verify = verify; self.resendVerification = resendVerification self.cooldown = cooldown ?? AccountAccessCooldown() - self.isDemo = isDemo + self.isDemo = isDemo; self.ownerIsCurrent = ownerIsCurrent } var canResend: Bool { !busy && now() >= resendAfter } func signUp() async { guard !busy, now() >= submitAfter else { return } let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, trimmed.count <= 200 else { error = "Enter your name (up to 200 characters)."; return } - guard validEmail else { error = "Enter a valid email address."; return } + guard !trimmed.isEmpty, trimmed.count <= 200 else { error = String(localized: "Enter your name (up to 200 characters)."); return } + guard validEmail else { error = String(localized: "Enter a valid email address."); return } guard validatePasswords() else { return } - guard password.count <= 128 else { error = "Use a password with no more than 128 characters."; return } - guard phone.count <= 32 else { error = "Use a phone number with no more than 32 characters."; return } + guard password.count <= 128 else { error = String(localized: "Use a password with no more than 128 characters."); return } + guard phone.count <= 32 else { error = String(localized: "Use a phone number with no more than 32 characters."); return } await perform { try await self.create(trimmed, self.email.trimmingCharacters(in: .whitespacesAndNewlines), self.password, self.phone) self.clearSecrets(); self.step = .created @@ -72,17 +73,17 @@ final class AccountAccessState: Identifiable { } func requestCode(resending: Bool = false) async { guard !busy, now() >= resendAfter, now() >= submitAfter else { return } - guard validEmail else { error = "Enter a valid email address."; return } + guard validEmail else { error = String(localized: "Enter a valid email address."); return } await perform { try await self.request(self.email.trimmingCharacters(in: .whitespacesAndNewlines)) self.resendAfter = self.now().addingTimeInterval(60) - self.notice = "If an account matches this email, a recovery code will arrive. Delivery may take a few minutes." + self.notice = String(localized: "If an account matches this email, a recovery code will arrive. Delivery may take a few minutes.") self.step = .confirmReset } } func confirmReset() async { guard !busy, now() >= submitAfter else { return } - guard isSixDigits else { error = "Enter the six-digit verification code."; return } + guard isSixDigits else { error = String(localized: "Enter the six-digit verification code."); return } guard validatePasswords() else { return } busy = true; error = nil defer { busy = false } @@ -91,21 +92,26 @@ final class AccountAccessState: Identifiable { clearSecrets(); step = .resetComplete } catch { // Every server rejection is identical: don't expose an OTP/account-existence oracle. - self.error = "Invalid or expired verification code." + self.error = String(localized: "Invalid or expired verification code.") preserveCooldown(error) } } func confirmEmail() async { guard !busy, now() >= submitAfter else { return } - guard isSixDigits else { error = "Enter the six-digit verification code."; return } - await perform { try await self.verify(self.code); self.code = ""; self.notice = "Email confirmed." } + guard isSixDigits else { error = String(localized: "Enter the six-digit verification code."); return } + await perform { + try await self.verify(self.code) + guard self.ownerIsCurrent() else { throw CancellationError() } + self.code = ""; self.notice = String(localized: "Email confirmed.") + } } func resendEmail() async { guard canResend, now() >= submitAfter else { return } await perform { try await self.resendVerification() + guard self.ownerIsCurrent() else { throw CancellationError() } self.resendAfter = self.now().addingTimeInterval(60) - self.notice = "Check your email for a verification code." + self.notice = self.isDemo ? String(localized: "Demo code ready. Use 123456. No email was sent.") : String(localized: "Check your email for a verification code.") } } func changeEmail() { @@ -120,8 +126,8 @@ final class AccountAccessState: Identifiable { return value.count <= 254 && value.range(of: #"^[^\s@]+@[^\s@]+\.[^\s@]+$"#, options: .regularExpression) != nil } private func validatePasswords() -> Bool { - guard password.count >= 8 else { error = "Use at least 8 characters."; return false } - guard password == confirmation else { error = "Passwords don't match."; return false } + guard password.count >= 8 else { error = String(localized: "Use at least 8 characters."); return false } + guard password == confirmation else { error = String(localized: "Passwords don't match."); return false } return true } private func preserveCooldown(_ failure: Error) { @@ -133,6 +139,7 @@ final class AccountAccessState: Identifiable { busy = true; error = nil defer { busy = false } do { try await operation() } catch { + guard ownerIsCurrent() else { return } preserveCooldown(error) if APIError.code(error) == "recovery_unavailable" { step = .unavailable; clearSecrets() } else { self.error = APIError.display(error) } diff --git a/SpaceWorks/AccountAccessView.swift b/SpaceWorks/AccountAccessView.swift index c45baea..778c35c 100644 --- a/SpaceWorks/AccountAccessView.swift +++ b/SpaceWorks/AccountAccessView.swift @@ -14,30 +14,30 @@ struct AccountAccessView: View { Text(explanation).foregroundStyle(Color.swMuted) switch form.step { case .signUp: - input("Name", value: $form.name, id: "signup-name", type: .name) - input("Email", value: $form.email, id: "account-email", type: .emailAddress) - PasswordControl(title: "Password", text: $form.password, newPassword: true, identifier: "account-password") - PasswordControl(title: "Confirm password", text: $form.confirmation, newPassword: true, identifier: "account-confirm-password") + input(String(localized: "Name"), value: $form.name, id: "signup-name", type: .name) + input(String(localized: "Email"), value: $form.email, id: "account-email", type: .emailAddress) + PasswordControl(title: String(localized: "Password"), text: $form.password, newPassword: true, identifier: "account-password") + PasswordControl(title: String(localized: "Confirm password"), text: $form.confirmation, newPassword: true, identifier: "account-confirm-password") DisclosureGroup("Phone number (optional)") { - input("Phone number", value: $form.phone, id: "account-phone", type: .telephoneNumber) + input(String(localized: "Phone number"), value: $form.phone, id: "account-phone", type: .telephoneNumber) }.padding(.vertical, 8) - primary("Create account") { await form.signUp() } + primary(String(localized: "Create account")) { await form.signUp() } case .requestReset: - input("Email", value: $form.email, id: "account-email", type: .emailAddress) - primary("Request recovery code") { await form.requestCode() } + input(String(localized: "Email"), value: $form.email, id: "account-email", type: .emailAddress) + primary(String(localized: "Request recovery code")) { await form.requestCode() } case .confirmReset: Text(form.email).textSelection(.enabled).foregroundStyle(Color.swMuted) - codeInput("Six-digit code") - PasswordControl(title: "New password", text: $form.password, newPassword: true, identifier: "account-password") - PasswordControl(title: "Confirm password", text: $form.confirmation, newPassword: true, identifier: "account-confirm-password") - primary("Update password") { await form.confirmReset() } - resend("Request another code") { await form.requestCode(resending: true) } + codeInput(String(localized: "Six-digit code")) + PasswordControl(title: String(localized: "New password"), text: $form.password, newPassword: true, identifier: "account-password") + PasswordControl(title: String(localized: "Confirm password"), text: $form.confirmation, newPassword: true, identifier: "account-confirm-password") + primary(String(localized: "Update password")) { await form.confirmReset() } + resend(String(localized: "Request another code")) { await form.requestCode(resending: true) } Button("Change email") { form.changeEmail() }.frame(minHeight: 48) case .emailVerification: Text(form.email).textSelection(.enabled).foregroundStyle(Color.swMuted) - codeInput("Verification code") - primary("Confirm email") { await form.confirmEmail() } - resend("Send another code") { await form.resendEmail() } + codeInput(String(localized: "Verification code")) + primary(String(localized: "Confirm email")) { await form.confirmEmail() } + resend(String(localized: "Send another code")) { await form.resendEmail() } case .created, .resetComplete, .unavailable: Image(systemName: form.step == .unavailable ? "person.crop.circle.badge.questionmark" : "checkmark.circle") .font(.largeTitle).foregroundStyle(Color.swAccentInk).accessibilityHidden(true) @@ -59,32 +59,33 @@ struct AccountAccessView: View { } private var heading: String { switch form.step { - case .signUp: return "Create your account" - case .created: return "Account created" - case .requestReset: return "Reset password" - case .confirmReset: return "Enter your recovery code" - case .resetComplete: return "Password updated" - case .unavailable: return "Password recovery unavailable" - case .emailVerification: return "Confirm your email" + case .signUp: return String(localized: "Create your account") + case .created: return String(localized: "Account created") + case .requestReset: return String(localized: "Reset password") + case .confirmReset: return String(localized: "Enter your recovery code") + case .resetComplete: return String(localized: "Password updated") + case .unavailable: return String(localized: "Password recovery unavailable") + case .emailVerification: return String(localized: "Confirm your email") } } private var explanation: String { if form.isDemo { switch form.step { - case .requestReset, .confirmReset: return "No message is sent in this demo. Use the sample recovery code 123456." - case .created: return "Simulated account creation. No account was created on a server." - case .resetComplete: return "Simulated password update. No real account was changed." + case .requestReset, .confirmReset: return String(localized: "No message is sent in this demo. Use the sample recovery code 123456.") + case .emailVerification: return String(localized: "No message is sent in this demo. Use the sample verification code 123456.") + case .created: return String(localized: "Simulated account creation. No account was created on a server.") + case .resetComplete: return String(localized: "Simulated password update. No real account was changed.") default: break } } switch form.step { - case .signUp: return "Use the email you want to use with your makerspace." - case .created: return "Sign in to continue. You may need to confirm your email." - case .requestReset: return "Enter your account email to request a six-digit recovery code." - case .confirmReset: return "If an account matches this email, a recovery code will arrive. Delivery may take a few minutes." - case .resetComplete: return "Your password has been updated. You can now sign in." - case .unavailable: return "Contact your makerspace staff to reset your password." - case .emailVerification: return "Enter the six-digit code sent to your email." + case .signUp: return String(localized: "Use the email you want to use with your makerspace.") + case .created: return String(localized: "Sign in to continue. You may need to confirm your email.") + case .requestReset: return String(localized: "Enter your account email to request a six-digit recovery code.") + case .confirmReset: return String(localized: "If an account matches this email, a recovery code will arrive. Delivery may take a few minutes.") + case .resetComplete: return String(localized: "Your password has been updated. You can now sign in.") + case .unavailable: return String(localized: "Contact your makerspace staff to reset your password.") + case .emailVerification: return String(localized: "Enter the six-digit code sent to your email.") } } private func input(_ title: String, value: Binding, id: String, type: UITextContentType) -> some View { @@ -103,7 +104,7 @@ struct AccountAccessView: View { Text(title).font(.headline) TextField(title, text: $form.code, prompt: Text(title).foregroundColor(Color.swMuted)).keyboardType(.numberPad).textContentType(.oneTimeCode) .textInputAutocapitalization(.never).autocorrectionDisabled().monospacedDigit() - .accessibilityIdentifier("account-code").padding(14).frame(minHeight: 48) + .focused($focused, equals: "account-code").accessibilityIdentifier("account-code").padding(14).frame(minHeight: 48) .background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)) } diff --git a/SpaceWorks/AccountDemo.swift b/SpaceWorks/AccountDemo.swift new file mode 100644 index 0000000..fbd81f8 --- /dev/null +++ b/SpaceWorks/AccountDemo.swift @@ -0,0 +1,124 @@ +#if DEBUG +import Foundation +import SpaceWorksAPI +import SwiftUI + +enum DemoAccountScenario: String, CaseIterable, Sendable { + case populated = "Populated", requiredMFA = "Required MFA", noMFA = "No authenticator" + case blockedMembership = "Outstanding loan", offline = "Error", throttled = "Too many requests" +} +enum DemoAccountLinkOutcome: String, CaseIterable, Sendable { case success = "Success", cancelled = "Cancelled", unavailable = "Unavailable", conflict = "Already linked elsewhere" } +protocol DemoAccountLinking: AccountRepository { + func linkProvider(_ provider: String, outcome: DemoAccountLinkOutcome) async throws -> Components.Schemas.SocialIdentity +} + +actor DemoAccountRepository: DemoAccountLinking { + nonisolated let isDemo = true + let scenario: DemoAccountScenario + private var revokedSessions = Set() + private var revokedGrants = Set() + private var providerNames = ["google"] + private var exportStatus = "available" + private var renewalCancelled = false + private var left = false + private var phonePending: String? + private var linkedPhone = "" + private var enabled = true + init(scenario: DemoAccountScenario = .populated) { self.scenario = scenario; enabled = scenario != .noMFA } + private func checked() throws { + if scenario == .offline { throw URLError(.notConnectedToInternet) } + if scenario == .throttled { throw APIError(status: 429, code: "throttled", detail: String(localized: "Too many requests. Try again later."), retryAfter: Date().addingTimeInterval(60)) } + } + private func decode(_ json: String, changes: [String: Any] = [:]) throws -> T { + try checked(); var object = try JSONSerialization.jsonObject(with: Data(json.utf8)) as! [String: Any] + object.merge(changes) { _, new in new }; let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(T.self, from: JSONSerialization.data(withJSONObject: object)) + } + func grants() throws -> [Components.Schemas.DeviceGrant] { + let rows = try JSONSerialization.jsonObject(with: Data(DebugAccountFixtures.grants.utf8)) as! [[String: Any]] + return try rows.filter { !revokedGrants.contains($0["id"] as! String) }.map { try decode(String(decoding: JSONSerialization.data(withJSONObject: $0), as: UTF8.self)) } + } + func revokeGrant(id: String) throws { try checked(); revokedGrants.insert(id); revokedSessions.insert("device:" + id) } + func sessions(page: Int) throws -> Components.Schemas.AccountSessionPage { + let value: Components.Schemas.AccountSessionPage = try decode(DebugAccountFixtures.sessions) + let rows = page == 1 ? value.results.filter { !revokedSessions.contains($0.id) } : [] + return .init(count: rows.count, next: nil, previous: nil, results: rows) + } + func revokeSession(id: String) throws { try checked(); revokedSessions.insert(id) } + func membership() throws -> Components.Schemas.MembershipAccount { + try decode(DebugAccountFixtures.membership, changes: ["status": left ? "revoked" : "active", "renewal_cancelled_at": renewalCancelled ? ISO8601DateFormatter().string(from: Date()) : NSNull()]) + } + func leave(reason: String) throws -> Components.Schemas.MembershipAccount { + try checked(); if scenario == .blockedMembership { throw APIError(status: 423, code: "outstanding_loans", detail: String(localized: "Return outstanding equipment before leaving this makerspace.")) } + left = true; return try membership() + } + func cancelRenewal(reason: String) throws -> Components.Schemas.MembershipAccount { try checked(); renewalCancelled = true; return try membership() } + func exports(page: Int) throws -> Components.Schemas.SubjectExportList { + let row = try export(id: "84000000-0000-4000-8000-000000000001") + return .init(count: 1, next: nil, previous: nil, results: page == 1 ? [row] : []) + } + func requestExport() throws -> Components.Schemas.SubjectExport { try checked(); exportStatus = "pending"; return try exportRow() } + func export(id: String) throws -> Components.Schemas.SubjectExport { try checked(); exportStatus = "available"; return try exportRow() } + private func exportRow() throws -> Components.Schemas.SubjectExport { + try decode(DebugAccountFixtures.export, changes: ["status": exportStatus, "expires_at": ISO8601DateFormatter().string(from: Date().addingTimeInterval(7 * 86400))]) + } + func exportDownload(id: String) throws -> Components.Schemas.DataExportDownloadUrl { + try checked(); return .init(url: "https://demo.example.invalid/api/v1/data-exports/download/\(id)/demo-token", expires_at: Date().addingTimeInterval(300)) + } + func exportArchive(id: String) throws -> Data { + try checked(); let object = try JSONSerialization.jsonObject(with: Data(DebugAccountFixtures.archive.utf8)) as! [String: String] + guard let data = Data(base64Encoded: object["base64"] ?? "") else { throw NativeTransferError.unsupportedDocument }; return data + } + func mfa() throws -> Components.Schemas.MFAStatus { try decode(DebugAccountFixtures.mfa, changes: ["required": scenario == .requiredMFA, "enabled": enabled]) } + func startMFA(password: String) throws -> Components.Schemas.MFAChallengeResponse { try validPassword(password); return try decode(DebugAccountFixtures.challenge) } + func disableMFA(password: String) throws -> Components.Schemas.MFAStatus { + try validPassword(password); guard scenario != .requiredMFA else { throw APIError(status: 403, code: "mfa_required", detail: String(localized: "This account requires two-step sign-in.")) } + enabled = false; return try mfa() + } + func regenerateRecovery(password: String) throws -> Components.Schemas.MFARecovery { try validPassword(password); return try decode(DebugAccountFixtures.recovery) } + private func validPassword(_ value: String) throws { + try checked(); if value != "DemoPass!123" { throw APIError(status: 0, code: "password_verification_failed", detail: String(localized: "Password verification failed.")) } + } + func startPhone(phone: String) throws -> Components.Schemas.PhoneStartResponse { try checked(); phonePending = phone; return .init(detail: "Demo code sent. No message was sent.") } + func confirmPhone(phone: String, code: String) throws -> Components.Schemas.PhoneStatus { + try checked(); guard phonePending == phone, code == "123456" else { throw APIError(status: 400, code: "invalid_code", detail: String(localized: "Invalid or expired verification code.")) } + linkedPhone = phone; phonePending = nil; return .init(phone_e164: phone, verified: true) + } + func unlinkPhone() throws -> Components.Schemas.PhoneStatus { try checked(); linkedPhone = ""; phonePending = nil; return .init(phone_e164: "", verified: false) } + func providers() throws -> [Components.Schemas.SocialIdentity] { try providerNames.map { try decode(DebugAccountFixtures.provider, changes: ["provider": $0]) } } + func unlinkProvider(_ provider: String) throws { + try checked(); guard providerNames.count > 1 else { throw APIError(status: 409, code: "last_sign_in_method", detail: String(localized: "Add another sign-in method before removing this one.")) }; providerNames.removeAll { $0 == provider } + } + func linkProvider(_ provider: String, outcome: DemoAccountLinkOutcome) throws -> Components.Schemas.SocialIdentity { + try checked() + switch outcome { + case .cancelled: throw CancellationError() + case .unavailable: throw APIError(status: 503, code: "provider_unavailable", detail: String(localized: "This provider is unavailable. Try again later.")) + case .conflict: throw APIError(status: 409, code: "provider_already_linked", detail: String(localized: "This sign-in method is already connected to another account.")) + case .success: if !providerNames.contains(provider) { providerNames.append(provider) }; return try decode(DebugAccountFixtures.provider, changes: ["provider": provider]) + } + } + func changePassword(current: String, new: String) throws { try validPassword(current) } + func validateSession() throws { + try checked(); if revokedGrants.contains("83000000-0000-4000-8000-000000000001") { throw APIError(status: 401, code: "device_revoked", detail: String(localized: "This device was signed out.")) } + } +} + +struct DemoAccountProviderLinks: View { + let state: AccountState + @State private var provider: String? + @State private var outcome = DemoAccountLinkOutcome.success + var body: some View { + SWCard { + Text("Preview provider linking").font(.headline) + Text("Demo mode. No provider account is connected.").foregroundStyle(Color.swMuted) + Picker("Preview result", selection: $outcome) { ForEach(DemoAccountLinkOutcome.allCases, id: \.self) { Text(LocalizedStringKey($0.rawValue)) } }.frame(minHeight: 48) + if !state.providers.contains(where: { $0.provider == "google" }) { GoogleProviderButton(wide: false) { provider = "google" } } + if !state.providers.contains(where: { $0.provider == "apple" }) { AppleProviderButton { provider = "apple" }.frame(height: 48) } + }.confirmationDialog("Connect a sample provider?", isPresented: Binding(get: { provider != nil }, set: { if !$0 { provider = nil } }), titleVisibility: .visible) { + if let provider { Button("Continue demo account") { self.provider = nil; Task { await state.previewProviderLink(provider, outcome: outcome) } } } + Button("Cancel", role: .cancel) { provider = nil } + } + } +} +#endif diff --git a/SpaceWorks/AccountMakerspaceLanding.swift b/SpaceWorks/AccountMakerspaceLanding.swift new file mode 100644 index 0000000..78c8c6d --- /dev/null +++ b/SpaceWorks/AccountMakerspaceLanding.swift @@ -0,0 +1,14 @@ +import SwiftUI + +/// Own global account state outside pushed destinations and before any makerspace is selected. +struct AccountMakerspaceLanding: View { + let auth: AuthState + @State private var account: AccountState? + @State private var owner = UUID() + init(auth: AuthState) { self.auth = auth; _account = State(initialValue: auth.accountState(makerspaceID: nil)) } + var body: some View { + NavigationStack { MakerspacePicker(auth: auth, account: account).navigationTitle("Your makerspaces").navigationBarTitleDisplayMode(.inline) } + .task { auth.registerPrivateHolder(owner) { [account] in account?.invalidate() } } + .onDisappear { auth.unregisterPrivateHolder(owner); account?.invalidate() } + } +} diff --git a/SpaceWorks/AccountPasswordView.swift b/SpaceWorks/AccountPasswordView.swift new file mode 100644 index 0000000..0fef792 --- /dev/null +++ b/SpaceWorks/AccountPasswordView.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct AccountPasswordView: View { + let state: AccountState + @State private var current = "" + @State private var new = "" + @State private var confirmation = "" + var body: some View { + ScrollView { VStack(alignment: .leading, spacing: 24) { + Text("Change password").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text("Choose a unique password. Your server checks its password policy.").foregroundStyle(Color.swMuted) + if state.isDemo { Text("Demo only. Use DemoPass!123 as the current password. No password will be changed.").foregroundStyle(Color.swMuted) } + SWCard { + PasswordControl(title: String(localized: "Current password"), text: $current, identifier: "account-password-current") + PasswordControl(title: String(localized: "New password"), text: $new, newPassword: true, identifier: "account-password-new") + PasswordControl(title: String(localized: "Confirm password"), text: $confirmation, newPassword: true, identifier: "account-password-confirm") + Text("If you don't have a current password, sign out and use Forgot password to recover access.").font(.subheadline).foregroundStyle(Color.swMuted) + if let error = state.error { SWError(message: error) } + if let notice = state.notice { Label(notice, systemImage: "checkmark.circle").foregroundStyle(Color.swMuted).accessibilityIdentifier("account-password-notice") } + TimelineView(.periodic(from: .now, by: 1)) { tick in + Button(state.busy ? "Updating…" : "Update password") { Task { + let updated = await state.changePassword(current: current, new: new, confirmation: confirmation) + current = ""; if updated { new = ""; confirmation = "" } + } }.buttonStyle(SWPrimary()).disabled(!state.canRequest || current.isEmpty || new.isEmpty || confirmation.isEmpty) + .accessibilityIdentifier("account-password-update") + if tick.date < state.retryAfter { Text("Try again after \(state.retryAfter.formatted(date: .omitted, time: .shortened)).").foregroundStyle(Color.swMuted) } + } + }.disabled(state.busy) + }.padding(24).frame(maxWidth: 640).frame(maxWidth: .infinity) }.background(Color.swBg).foregroundStyle(Color.swInk) + .navigationTitle("Change password").navigationBarTitleDisplayMode(.inline).scrollDismissesKeyboard(.interactively) + .onDisappear { current = ""; new = ""; confirmation = "" } + } +} diff --git a/SpaceWorks/AccountRepository.swift b/SpaceWorks/AccountRepository.swift new file mode 100644 index 0000000..11902e6 --- /dev/null +++ b/SpaceWorks/AccountRepository.swift @@ -0,0 +1,218 @@ +import Foundation +import SpaceWorksAPI + +protocol AccountRepository: Sendable { + var isDemo: Bool { get } + func grants() async throws -> [Components.Schemas.DeviceGrant] + func revokeGrant(id: String) async throws + func sessions(page: Int) async throws -> Components.Schemas.AccountSessionPage + func revokeSession(id: String) async throws + func membership() async throws -> Components.Schemas.MembershipAccount + func leave(reason: String) async throws -> Components.Schemas.MembershipAccount + func cancelRenewal(reason: String) async throws -> Components.Schemas.MembershipAccount + func exports(page: Int) async throws -> Components.Schemas.SubjectExportList + func requestExport() async throws -> Components.Schemas.SubjectExport + func export(id: String) async throws -> Components.Schemas.SubjectExport + func exportDownload(id: String) async throws -> Components.Schemas.DataExportDownloadUrl + func exportArchive(id: String) async throws -> Data + func mfa() async throws -> Components.Schemas.MFAStatus + func startMFA(password: String) async throws -> Components.Schemas.MFAChallengeResponse + func disableMFA(password: String) async throws -> Components.Schemas.MFAStatus + func regenerateRecovery(password: String) async throws -> Components.Schemas.MFARecovery + func startPhone(phone: String) async throws -> Components.Schemas.PhoneStartResponse + func confirmPhone(phone: String, code: String) async throws -> Components.Schemas.PhoneStatus + func unlinkPhone() async throws -> Components.Schemas.PhoneStatus + func providers() async throws -> [Components.Schemas.SocialIdentity] + func unlinkProvider(_ provider: String) async throws + func changePassword(current: String, new: String) async throws + func validateSession() async throws +} +extension AccountRepository { var isDemo: Bool { false } } + +/// Caller owns clear/re-auth transitions after current grant/session revocation or MFA changes. +/// Scope remains captured across all actor suspension, including global account operations. +struct LiveAccountRepository: AccountRepository { + let service: SpaceWorksService + let expectedCredentials: CredentialSnapshot + let makerspaceID: Int? + let origin: ServerOrigin + var onUnauthorized: @Sendable (String) async -> Void = { _ in } + var onForbidden: @Sendable () async -> Void = {} + + private func checked(passwordVerification: Bool = false, global: Bool = false, _ work: @Sendable () async throws -> T) async throws -> T { + // Catch and password-identity probes remain inside the owning installation's task scope. + guard await service.refresh.isSameInstallation(expectedCredentials) else { throw CancellationError() } + if await service.refresh.current() == nil { + let failure = APIError(status: 401, code: "signed_out", detail: String(localized: "Please sign in again.")) + guard await service.refresh.isSameInstallation(expectedCredentials) else { throw CancellationError() } + await onUnauthorized(failure.detail); throw failure + } + return try await service.withSession(expectedCredentials: expectedCredentials) { + do { + if global { return try await work() } + return try await service.withMakerspace(try requireMakerspace(), operation: work) + } catch { + let original = error + if Task.isCancelled || AccountOperationFailure.isCancellation(error) { throw original } + guard await service.refresh.isSameInstallation(expectedCredentials) else { throw CancellationError() } + let empty = await service.refresh.current() == nil + if passwordVerification, APIError.status(original) == 401, !empty { + var verifiedIdentity = false + do { + _ = try await service.withSession(expectedCredentials: expectedCredentials) { try await service.me() } + verifiedIdentity = true + } catch { + if Task.isCancelled || AccountOperationFailure.isCancellation(error) { throw error } + guard await service.refresh.isSameInstallation(expectedCredentials) else { throw CancellationError() } + let probeEmpty = await service.refresh.current() == nil + if APIError.status(error) == 401 || probeEmpty { await onUnauthorized(APIError.display(error)) } + else if APIError.status(error) == 403 { await onForbidden() } + // An unavailable identity probe cannot turn an otherwise owned session + // into a confirmed401. Keep its real retry/offline failure actionable. + throw error + } + guard await service.refresh.isSameInstallation(expectedCredentials) else { throw CancellationError() } + if verifiedIdentity { + throw APIError(status: 0, code: "password_verification_failed", detail: APIError.display(original), retryAfter: APIError.retryDeadline(original)) + } + throw original + } + guard await service.refresh.isSameInstallation(expectedCredentials) else { throw CancellationError() } + if APIError.status(original) == 401 || empty { await onUnauthorized(APIError.display(original)) } + else if APIError.status(original) == 403 { await onForbidden() } + throw original + } + } + } + func grants() async throws -> [Components.Schemas.DeviceGrant] { try await checked(global: true) { try await service.accountGrants() } } + func revokeGrant(id: String) async throws { try validUUID(id); try await checked(global: true) { try await service.accountRevokeGrant(id: id) } } + func sessions(page: Int) async throws -> Components.Schemas.AccountSessionPage { + try firstPage(page) + let makerspaceID = try requireMakerspace() + let result = try await checked { try await service.accountSessions(makerspaceID: makerspaceID) } + try validateNext(result.next, path: "/api/v1/member/makerspaces/\(makerspaceID)/account/sessions"); return result + } + func revokeSession(id: String) async throws { + let makerspaceID = try requireMakerspace() + guard !id.isEmpty, id.utf8.count <= 512, !id.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { throw invalid(String(localized: "Choose a valid session.")) } + try await checked { try await service.accountRevokeSession(makerspaceID: makerspaceID, id: id) } + } + func membership() async throws -> Components.Schemas.MembershipAccount { let makerspaceID = try requireMakerspace(); return try await checked { try await service.accountMembership(makerspaceID: makerspaceID) } } + func leave(reason: String) async throws -> Components.Schemas.MembershipAccount { + let makerspaceID = try requireMakerspace() + let value = try validReason(reason); return try await checked { try await service.accountLeave(makerspaceID: makerspaceID, reason: value) } + } + func cancelRenewal(reason: String) async throws -> Components.Schemas.MembershipAccount { + let makerspaceID = try requireMakerspace() + let value = try validReason(reason); return try await checked { try await service.accountCancelRenewal(makerspaceID: makerspaceID, reason: value) } + } + func exports(page: Int) async throws -> Components.Schemas.SubjectExportList { + try firstPage(page) + let makerspaceID = try requireMakerspace() + let result = try await checked { try await service.accountExports(makerspaceID: makerspaceID) } + try validateNext(result.next, path: "/api/v1/member/makerspaces/\(makerspaceID)/account/exports"); return result + } + func requestExport() async throws -> Components.Schemas.SubjectExport { let makerspaceID = try requireMakerspace(); return try await checked { try await service.accountRequestExport(makerspaceID: makerspaceID) } } + func export(id: String) async throws -> Components.Schemas.SubjectExport { + let makerspaceID = try requireMakerspace() + try validUUID(id); return try await checked { try await service.accountExport(makerspaceID: makerspaceID, id: id) } + } + func exportDownload(id: String) async throws -> Components.Schemas.DataExportDownloadUrl { + let makerspaceID = try requireMakerspace() + try validUUID(id) + let result = try await checked { try await service.accountExportDownload(makerspaceID: makerspaceID, id: id) } + guard result.expires_at > Date(), result.url.utf8.count <= 8192, !result.url.contains("\\"), + !result.url.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }), + let url = URL(string: result.url, relativeTo: origin.url)?.absoluteURL, + let parts = URLComponents(url: url, resolvingAgainstBaseURL: true), + parts.scheme == origin.url.scheme, parts.host?.lowercased() == origin.url.host?.lowercased(), + effectivePort(url) == effectivePort(origin.url), parts.user == nil, parts.password == nil, + parts.query == nil, parts.fragment == nil else { throw invalid(String(localized: "This export link is unavailable. Request a new link and try again.")) } + let prefix = "/api/v1/data-exports/download/\(id)/" + guard parts.percentEncodedPath.hasPrefix(prefix) else { throw invalid(String(localized: "This export link is unavailable. Request a new link and try again.")) } + let token = String(parts.percentEncodedPath.dropFirst(prefix.count)) + guard !token.isEmpty, token.utf8.count <= 4096, !token.contains("/"), + let decoded = token.removingPercentEncoding, !decoded.isEmpty, + decoded != ".", decoded != "..", !decoded.contains("/"), !decoded.contains("\\"), + !decoded.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { + throw invalid(String(localized: "This export link is unavailable. Request a new link and try again.")) + } + return result + } + func exportArchive(id: String) async throws -> Data { + let makerspaceID = try requireMakerspace() + let grant = try await exportDownload(id: id) + guard let parts = URLComponents(string: grant.url), + let token = parts.percentEncodedPath.split(separator: "/").last.flatMap({ String($0).removingPercentEncoding }) else { + throw invalid(String(localized: "This export link is unavailable. Request a new link and try again.")) + } + return try await checked { + guard await service.makerspace.current() == makerspaceID, + await service.refresh.current() != nil else { throw CancellationError() } + return try await service.accountExportArchive(id: id, token: token) + } + } + func mfa() async throws -> Components.Schemas.MFAStatus { try await checked(global: true) { try await service.accountMFA() } } + func startMFA(password: String) async throws -> Components.Schemas.MFAChallengeResponse { + // Source rejects any device_grant here. Public A1 enrollment is only for a native + // device-login challenge, which already carries server-owned attestation context. + throw APIError(status: 0, code: "native_mfa_management_unavailable", detail: String(localized: "Enable two-step verification in your browser, then sign in again on this device.")) + } + func disableMFA(password: String) async throws -> Components.Schemas.MFAStatus { + try validPassword(password); return try await checked(passwordVerification: true, global: true) { try await service.accountDisableMFA(password: password) } + } + func regenerateRecovery(password: String) async throws -> Components.Schemas.MFARecovery { + try validPassword(password); return try await checked(passwordVerification: true, global: true) { try await service.accountRegenerateRecovery(password: password) } + } + func startPhone(phone: String) async throws -> Components.Schemas.PhoneStartResponse { + try validPhone(phone); return try await checked(global: true) { try await service.accountStartPhone(phone: phone) } + } + func confirmPhone(phone: String, code: String) async throws -> Components.Schemas.PhoneStatus { + try validPhone(phone) + guard code.count == 6, code.allSatisfy({ $0.isASCII && $0.isNumber }) else { throw invalid(String(localized: "Enter the six-digit verification code.")) } + return try await checked(global: true) { try await service.accountConfirmPhone(phone: phone, code: code) } + } + func unlinkPhone() async throws -> Components.Schemas.PhoneStatus { try await checked(global: true) { try await service.accountUnlinkPhone() } } + func providers() async throws -> [Components.Schemas.SocialIdentity] { try await checked(global: true) { try await service.accountProviders() } } + func unlinkProvider(_ provider: String) async throws { + guard !provider.isEmpty, provider.utf8.count <= 200, !provider.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { throw invalid(String(localized: "Choose a linked sign-in provider.")) } + try await checked(global: true) { try await service.accountUnlinkProvider(provider) } + } + func updateTemporaryPassword(current: String, new: String) async throws { + try await checked(passwordVerification: true, global: true) { try await service.changePassword(current: current, new: new) } + } + func changePassword(current: String, new: String) async throws { + try await service.withSession(expectedCredentials: expectedCredentials) { + try await checked(passwordVerification: true, global: true) { try await service.changePassword(current: current, new: new) } + // The acknowledgement has no replacement grant. Validate identity after it; + // a failure here is session refusal, never a wrong-password classification. + _ = try await checked(global: true) { try await service.me() } + } + } + func validateSession() async throws { _ = try await checked(global: true) { try await service.me() } } + + private func requireMakerspace() throws -> Int { + guard let makerspaceID, makerspaceID > 0 else { throw APIError(status: 403, code: "account_membership_required", detail: String(localized: "Choose an active makerspace membership to use this account option.")) } + return makerspaceID + } + private func firstPage(_ page: Int) throws { + guard page == 1 else { throw APIError(status: 0, code: "account_pagination_unavailable", detail: String(localized: "More account records are available in your browser. This server has not provided native pagination for them.")) } + } + private func validateNext(_ next: String?, path: String) throws { + guard let next else { return } + guard next.utf8.count <= 8192, !next.contains("\\"), let base = URL(string: path, relativeTo: origin.url)?.absoluteURL, + let absolute = URL(string: next, relativeTo: base)?.absoluteURL, let url = try origin.nextPage(absolute.absoluteString), + let parts = URLComponents(url: url, resolvingAgainstBaseURL: true), parts.percentEncodedPath == path else { + throw invalid(String(localized: "The server returned an invalid account page.")) + } + } + private func validUUID(_ id: String) throws { guard UUID(uuidString: id) != nil else { throw invalid(String(localized: "Choose a valid account record.")) } } + private func validReason(_ reason: String) throws -> String { + let value = reason.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, value.count <= 1000 else { throw invalid(String(localized: "Add a reason of up to 1,000 characters.")) }; return value + } + private func validPassword(_ password: String) throws { guard !password.isEmpty, password.count <= 1024 else { throw invalid(String(localized: "Enter your current password.")) } } + private func validPhone(_ phone: String) throws { guard !phone.isEmpty, phone.count <= 32 else { throw invalid(String(localized: "Enter a valid phone number.")) } } + private func effectivePort(_ url: URL) -> Int { url.port ?? (url.scheme == "https" ? 443 : 80) } + private func invalid(_ message: String) -> APIError { APIError(status: 0, code: "invalid_account_input", detail: message) } +} diff --git a/SpaceWorks/AccountState.swift b/SpaceWorks/AccountState.swift new file mode 100644 index 0000000..c8bb07c --- /dev/null +++ b/SpaceWorks/AccountState.swift @@ -0,0 +1,378 @@ +import Foundation +import Observation +import SpaceWorksAPI + +/// Global security belongs to the account; makerspace records require a real enabled scope. +/// Secrets remain in memory only. +@MainActor @Observable +final class AccountState { + enum Load: Equatable { case idle, loading, ready, failed(String) } + let repository: any AccountRepository + let makerspaceID: Int? + private(set) var selfServiceEnabled: Bool + private(set) var exportEnabled: Bool + let makerspaceName: String + let currentDeviceGrantID: String? + private let beginSecurityEnrollment: (Components.Schemas.MFAChallengeResponse) async -> Void + private let endSession: ([String], String?) async -> Void + private let membershipEnded: () async -> Void + private let passwordChanged: () async -> Void + private let beforeLeave: () async throws -> Void + private let leaveFailed: () async -> Void + private let phoneCooldown: AccountAccessCooldown + private let now: () -> Date + private let cooldownSleep: @Sendable (UInt64) async throws -> Void + private var active = true + private var sessionGeneration = 0 + private var grantGeneration = 0 + private var securityGeneration = 0 + private var membershipGeneration = 0 + private var providerGeneration = 0 + private var exportGeneration = 0 + private var downloadGeneration = 0 + private(set) var sessions: [Components.Schemas.AccountSession] = [] + private(set) var grants: [Components.Schemas.DeviceGrant] = [] + private(set) var exports: [Components.Schemas.SubjectExport] = [] + private(set) var membership: Components.Schemas.MembershipAccount? + private(set) var mfa: Components.Schemas.MFAStatus? + private(set) var providers: [Components.Schemas.SocialIdentity] = [] + private(set) var phoneStatus: Components.Schemas.PhoneStatus? + private(set) var sessionsLoad = Load.idle + private(set) var grantsLoad = Load.idle + private(set) var exportsLoad = Load.idle + private(set) var membershipLoad = Load.idle + private(set) var securityLoad = Load.idle + private(set) var providersLoad = Load.idle + private(set) var sessionsHaveMore = false + private(set) var exportsHaveMore = false + private(set) var busy = false + private(set) var error: String? + private(set) var notice: String? + private(set) var retryAfter = Date.distantPast + var resendAfter: Date { phoneCooldown.resendAfter } + private(set) var pendingPhone: String? + var password = "" + var phone = "" + var phoneCountry = "+91" + var code = "" + var membershipReason = "" + var archive: NativeDocumentFile? + private(set) var downloading = false + @ObservationIgnored private var cooldownTask: Task? + init(repository: any AccountRepository, makerspaceID: Int?, makerspaceName: String, + selfServiceEnabled: Bool = false, exportEnabled: Bool = false, + currentDeviceGrantID: String? = nil, + phoneCooldown: AccountAccessCooldown? = nil, + now: @escaping () -> Date = Date.init, + cooldownSleep: @escaping @Sendable (UInt64) async throws -> Void = { try await Task.sleep(nanoseconds: $0) }, + beginSecurityEnrollment: @escaping (Components.Schemas.MFAChallengeResponse) async -> Void = { _ in }, + endSession: @escaping ([String], String?) async -> Void = { _, _ in }, + membershipEnded: @escaping () async -> Void = {}, passwordChanged: @escaping () async -> Void = {}, + beforeLeave: @escaping () async throws -> Void = {}, leaveFailed: @escaping () async -> Void = {}) { + self.repository = repository; self.makerspaceID = makerspaceID.flatMap { $0 > 0 ? $0 : nil }; self.makerspaceName = makerspaceName + self.selfServiceEnabled = selfServiceEnabled; self.exportEnabled = exportEnabled + self.currentDeviceGrantID = currentDeviceGrantID; self.beginSecurityEnrollment = beginSecurityEnrollment + self.phoneCooldown = phoneCooldown ?? AccountAccessCooldown() + self.now = now; self.cooldownSleep = cooldownSleep + self.endSession = endSession; self.membershipEnded = membershipEnded; self.passwordChanged = passwordChanged + self.beforeLeave = beforeLeave; self.leaveFailed = leaveFailed + } + var isDemo: Bool { repository.isDemo } + var isActive: Bool { active } + var canRequest: Bool { active && !busy && !downloading && now() >= retryAfter } + var memberSelfServiceAvailable: Bool { makerspaceID != nil && selfServiceEnabled } + var dataExportAvailable: Bool { makerspaceID != nil && exportEnabled } + func updateCapabilities(selfService: Bool, exports: Bool) { + if selfServiceEnabled && !selfService { + sessionGeneration += 1; membershipGeneration += 1 + sessions = []; membership = nil; membershipReason = ""; sessionsHaveMore = false + sessionsLoad = .idle; membershipLoad = .idle + } + if exportEnabled && !exports { + exportGeneration += 1; downloadGeneration += 1 + self.exports = []; exportsHaveMore = false; archive = nil; downloading = false; exportsLoad = .idle + } + selfServiceEnabled = selfService; exportEnabled = exports + } + var canStartMFA: Bool { canRequest && isDemo && mfa?.available == true && mfa?.enabled == false } + var canDisableMFA: Bool { canRequest && mfa?.enabled == true && mfa?.required == false } + var canRegenerateRecovery: Bool { canRequest && mfa?.enabled == true && mfa?.available == true } + #if DEBUG + func previewProviderLink(_ provider: String, outcome: DemoAccountLinkOutcome) async { + guard isDemo, canRequest, let demo = repository as? any DemoAccountLinking else { return } + busy = true; error = nil; notice = nil + do { + _ = try await demo.linkProvider(provider, outcome: outcome); guard active else { return } + busy = false; await loadProviders(); notice = String(localized: "Sample sign-in method connected.") + } catch { + guard active else { return }; busy = false + if error is CancellationError { notice = String(localized: "Demo linking cancelled.") } + else { fail(error) } + } + } + #endif + func invalidate() { + active = false; cooldownTask?.cancel(); cooldownTask = nil; clearPrivateData() + } + private func clearPrivateData(preservingBusy: Bool = false) { + sessionGeneration += 1; grantGeneration += 1; securityGeneration += 1; membershipGeneration += 1; providerGeneration += 1; exportGeneration += 1; downloadGeneration += 1 + sessions = []; grants = []; exports = []; membership = nil; mfa = nil; providers = []; phoneStatus = nil + password = ""; phone = ""; code = ""; pendingPhone = nil; membershipReason = ""; archive = nil + sessionsLoad = .idle; grantsLoad = .idle; membershipLoad = .idle; securityLoad = .idle; providersLoad = .idle; exportsLoad = .idle + sessionsHaveMore = false; exportsHaveMore = false + if !preservingBusy { busy = false }; downloading = false; error = nil; notice = nil + } + func loadSessions() async { + guard memberSelfServiceAvailable, canRequest else { return }; sessionGeneration += 1; let generation = sessionGeneration + sessionsLoad = .loading + do { + let result = try await repository.sessions(page: 1); guard active, generation == sessionGeneration else { return } + sessions = result.results; sessionsHaveMore = result.next != nil; sessionsLoad = .ready + } catch { guard active, generation == sessionGeneration else { return }; refuse(error); sessionsLoad = .failed(APIError.display(error)) } + } + func loadGrants() async { + guard canRequest else { return }; grantGeneration += 1; let generation = grantGeneration; grantsLoad = .loading + do { let result = try await repository.grants(); guard active, generation == grantGeneration else { return }; grants = result; grantsLoad = .ready } + catch { guard active, generation == grantGeneration else { return }; refuse(error); grantsLoad = .failed(APIError.display(error)) } + } + func loadMembership() async { + guard memberSelfServiceAvailable, canRequest else { return }; membershipGeneration += 1; let generation = membershipGeneration; membershipLoad = .loading + do { let result = try await repository.membership(); guard active, generation == membershipGeneration else { return }; membership = result; membershipLoad = .ready } + catch { guard active, generation == membershipGeneration else { return }; refuse(error); membershipLoad = .failed(APIError.display(error)) } + } + func loadSecurity() async { + guard canRequest, securityLoad != .loading else { return }; securityGeneration += 1; let generation = securityGeneration; securityLoad = .loading + do { let result = try await repository.mfa(); guard active, generation == securityGeneration else { return }; mfa = result; securityLoad = .ready } + catch { guard active, generation == securityGeneration else { return }; refuse(error); securityLoad = .failed(APIError.display(error)) } + } + func loadProviders() async { + guard canRequest else { return }; providerGeneration += 1; let generation = providerGeneration; providersLoad = .loading + do { let result = try await repository.providers(); guard active, generation == providerGeneration else { return }; providers = result; providersLoad = .ready } + catch { guard active, generation == providerGeneration else { return }; refuse(error); providersLoad = .failed(APIError.display(error)) } + } + func loadExports() async { + guard dataExportAvailable, canRequest else { return }; exportGeneration += 1; let generation = exportGeneration + exportsLoad = .loading + do { + let result = try await repository.exports(page: 1); guard active, generation == exportGeneration else { return } + exports = result.results; exportsHaveMore = result.next != nil; exportsLoad = .ready + } catch { guard active, generation == exportGeneration else { return }; refuse(error); exportsLoad = .failed(APIError.display(error)) } + } + func revokeSession(_ row: Components.Schemas.AccountSession) async { + guard memberSelfServiceAvailable, canRequest, sessions.contains(where: { $0.id == row.id }) else { return } + sessionGeneration += 1; let generation = sessionGeneration; sessionsLoad = .ready + busy = true; error = nil; notice = nil; defer { if active { busy = false } } + do { + try await repository.revokeSession(id: row.id); guard active else { return } + if row.current { invalidate(); await endSession([], "This session was signed out."); return } + guard generation == sessionGeneration, memberSelfServiceAvailable else { return } + sessions.removeAll { $0.id == row.id }; busy = false; notice = String(localized: "Session signed out.") + await loadGrants() + } catch { fail(error) } + } + func revokeGrant(_ row: Components.Schemas.DeviceGrant) async { + guard canRequest, grants.contains(where: { $0.id == row.id }) else { return } + grantGeneration += 1; grantsLoad = .ready + busy = true; error = nil; notice = nil + do { + try await repository.revokeGrant(id: row.id); guard active else { return } + if row.id == currentDeviceGrantID { invalidate(); await endSession([], "This device was signed out."); return } + // DeviceGrant has no 'current' flag. Never infer current identity from ordering or platform. + if currentDeviceGrantID == nil { + do { try await repository.validateSession() } + catch { + guard active else { return } + if AccountOperationFailure.isCancellation(error) { invalidate(); return } + invalidate(); await endSession([], "Device removed. Sign in again to refresh this session."); return + } + } + guard active else { return } + grants.removeAll { $0.id == row.id }; busy = false; notice = String(localized: "Device signed out."); if memberSelfServiceAvailable { await loadSessions() } + } catch { fail(error) } + } + func changePassword(current: String, new: String, confirmation: String) async -> Bool { + guard canRequest else { return false } + guard !current.isEmpty, !new.isEmpty else { error = String(localized: "Enter your current password and a new password."); return false } + guard new == confirmation else { error = String(localized: "Your new passwords don't match."); return false } + busy = true; error = nil; notice = nil; defer { if active { busy = false } } + do { + try await repository.changePassword(current: current, new: new); guard active else { return false } + // The endpoint returns an acknowledgement, not a replacement device grant. + await passwordChanged(); guard active else { return false } + notice = isDemo ? String(localized: "Password change preview complete. No password was changed.") : String(localized: "Password changed.") + return true + } catch { fail(error); return false } + } + func startAuthenticator() async { + guard canStartMFA, validPassword else { return }; busy = true; error = nil + let value = password; password = "" + do { + let challenge = try await repository.startMFA(password: value); guard active else { return } + invalidate(); await beginSecurityEnrollment(challenge) + } catch { fail(error) } + } + func regenerateRecovery() async { + guard canRegenerateRecovery, validPassword else { return }; busy = true; error = nil + let value = password; password = "" + do { + let result = try await repository.regenerateRecovery(password: value); guard active else { return } + let codes = result.recovery_codes + invalidate(); await endSession(codes, "Your previous sessions have ended. Save these recovery codes, then sign in again.") + } catch { fail(error) } + } + func disableAuthenticator() async { + guard canDisableMFA, validPassword else { return }; busy = true; error = nil + let value = password; password = "" + do { + let result = try await repository.disableMFA(password: value); guard active else { return } + invalidate(); await endSession([], result.enabled ? "Your previous sessions have ended. Sign in again to check two-step sign-in." : "Two-step sign-in was disabled. Your previous sessions have ended. Sign in again to continue.") + } catch { fail(error) } + } + private var validPassword: Bool { + guard !password.isEmpty, password.count <= 1024 else { error = String(localized: "Enter your current password."); return false }; return true + } + static func canonicalPhone(callingCode: String, number: String) -> String? { + let calling = callingCode.hasPrefix("+") ? String(callingCode.dropFirst()) : callingCode + guard (1...3).contains(calling.count), calling.first != "0", + calling.allSatisfy({ $0.isASCII && $0.isNumber }) else { return nil } + let incoming = number.trimmingCharacters(in: .whitespacesAndNewlines) + let international = incoming.hasPrefix("+") + let raw = international ? String(incoming.dropFirst()) : incoming + guard raw.allSatisfy({ ($0.isASCII && $0.isNumber) || " -()".contains($0) }) else { return nil } + let digits = raw.filter { $0.isASCII && $0.isNumber } + guard !international || digits.hasPrefix(calling) else { return nil } + let national = international ? String(digits.dropFirst(calling.count)) : digits + guard national.count >= 6, calling.count + national.count <= 15 else { return nil } + return "+" + calling + national + } + func sendPhoneCode() async { + guard canRequest, now() >= resendAfter else { return } + guard let value = pendingPhone ?? Self.canonicalPhone(callingCode: phoneCountry, number: phone) else { error = String(localized: "Enter a valid phone number."); return } + busy = true; error = nil; code = "" + do { + _ = try await repository.startPhone(phone: value); guard active else { return } + pendingPhone = value; phoneCooldown.resendAfter = now().addingTimeInterval(60); busy = false + notice = isDemo ? String(localized: "Sample code sent in demo mode. No message was sent.") : String(localized: "Verification code sent.") + } catch { + if let deadline = APIError.retryDeadline(error), deadline > phoneCooldown.resendAfter { phoneCooldown.resendAfter = deadline } + fail(error) + } + } + func confirmPhone() async { + guard canRequest, let pendingPhone else { return } + guard code.count == 6, code.allSatisfy({ $0.isASCII && $0.isNumber }) else { error = String(localized: "Enter the six-digit verification code."); return } + let value = code; code = ""; busy = true; error = nil + do { + let result = try await repository.confirmPhone(phone: pendingPhone, code: value); guard active else { return } + guard result.verified, result.phone_e164 == pendingPhone else { throw APIError(status: 0, code: "phone_verification_unconfirmed", detail: String(localized: "Phone verification wasn't confirmed. Request a new code and try again.")) } + phoneStatus = result; self.pendingPhone = nil; phone = ""; busy = false + notice = result.verified ? String(localized: "Phone number verified.") : String(localized: "This number could not be verified.") + } catch { + fail(error) + if active && ![401, 403, 429].contains(APIError.status(error)) { self.error = String(localized: "Invalid or expired verification code.") } + } + } + func changePhoneNumber() { guard !busy else { return }; pendingPhone = nil; code = ""; error = nil } + func unlinkPhone() async { + guard canRequest else { return }; busy = true; error = nil + do { + let result = try await repository.unlinkPhone(); guard active else { return } + guard !result.verified, result.phone_e164.isEmpty else { throw APIError(status: 0, code: "phone_unlink_unconfirmed", detail: String(localized: "Phone removal wasn't confirmed. Check the current number before trying again.")) } + phoneStatus = result; pendingPhone = nil; code = ""; phone = ""; busy = false; notice = String(localized: "Phone sign-in removed.") + } catch { fail(error) } + } + func unlinkProvider(_ provider: String) async { + guard canRequest, providers.contains(where: { $0.provider == provider }) else { return }; busy = true; error = nil; notice = nil + providerGeneration += 1; providersLoad = .ready + do { + try await repository.unlinkProvider(provider); guard active else { return } + providers.removeAll { $0.provider == provider }; busy = false; notice = String(localized: "Sign-in method removed.") + } catch { fail(error) } + } + func cancelRenewal() async { await changeMembership(leave: false) } + func leaveMakerspace() async { await changeMembership(leave: true) } + private func changeMembership(leave: Bool) async { + let reason = membershipReason.trimmingCharacters(in: .whitespacesAndNewlines) + guard memberSelfServiceAvailable else { error = String(localized: "Membership self-service isn't available for this account."); return } + guard canRequest, membership?.status.rawValue == "active", !reason.isEmpty, reason.count <= 1000 else { error = String(localized: "Add a reason of up to 1,000 characters."); return } + membershipGeneration += 1; let generation = membershipGeneration; membershipLoad = .ready + busy = true; error = nil; notice = nil; defer { if active { busy = false } } + do { + let result: Components.Schemas.MembershipAccount + if leave { + try await beforeLeave() + guard active, generation == membershipGeneration, memberSelfServiceAvailable else { + if active { await leaveFailed() } + return + } + result = try await repository.leave(reason: reason) + } + else { result = try await repository.cancelRenewal(reason: reason) } + guard active else { return } + if result.status.rawValue == "revoked" { invalidate(); await membershipEnded(); return } + guard generation == membershipGeneration, memberSelfServiceAvailable else { return } + membership = result; membershipReason = ""; busy = false + if !leave && result.renewal_cancelled_at != nil { notice = String(localized: "Membership renewal cancelled.") } + else { + error = String(localized: "This membership change wasn't confirmed. Refresh its status before trying again.") + if leave { await leaveFailed() } + } + } catch { if leave, active { await leaveFailed() }; if generation == membershipGeneration { fail(error) } } + } + func requestExport() async { + guard dataExportAvailable, canRequest else { return }; busy = true; error = nil; notice = nil; defer { if active { busy = false } } + exportGeneration += 1; let generation = exportGeneration; exportsLoad = .ready + do { + let result = try await repository.requestExport(); guard active, generation == exportGeneration, dataExportAvailable else { return } + exports.removeAll { $0.id == result.id }; exports.insert(result, at: 0); busy = false; exportsLoad = .ready + notice = String(localized: "Export requested. Check its status when you're ready.") + } catch { if generation == exportGeneration { fail(error) } } + } + func refreshExport(_ row: Components.Schemas.SubjectExport) async { + guard dataExportAvailable, canRequest, exports.contains(where: { $0.id == row.id }) else { return }; busy = true; error = nil; defer { if active { busy = false } } + exportGeneration += 1; let generation = exportGeneration; exportsLoad = .ready + do { + let result = try await repository.export(id: row.id); guard active, generation == exportGeneration, dataExportAvailable else { return } + exports = exports.map { $0.id == result.id ? result : $0 }; busy = false + } catch { if generation == exportGeneration { fail(error) } } + } + func canDownloadExport(_ row: Components.Schemas.SubjectExport, at date: Date? = nil) -> Bool { + guard dataExportAvailable, active, let current = exports.first(where: { $0.id == row.id }) else { return false } + return current.status.value1.rawValue == "available" && current.expires_at > (date ?? now()) + } + func downloadExport(_ row: Components.Schemas.SubjectExport) async { + guard dataExportAvailable, canRequest, !downloading, let current = exports.first(where: { $0.id == row.id }) else { return } + guard canDownloadExport(current) else { + if current.expires_at <= now() { error = String(localized: "This export expired. Request a new archive.") } + return + } + downloadGeneration += 1; let generation = downloadGeneration; downloading = true; error = nil; archive = nil + do { + let bytes = try await repository.exportArchive(id: row.id); guard active, generation == downloadGeneration else { return } + archive = try NativeFileTransfer.document(data: bytes, kind: .zip, suggestedName: isDemo ? "Space-Works-demo-export" : "Space-Works-data-export") + downloading = false + } catch { + guard active, generation == downloadGeneration else { return }; downloading = false; fail(error) + if let transfer = error as? NativeTransferError { self.error = transfer.errorDescription } + } + } + private func fail(_ error: Error) { guard active else { return }; refuse(error); self.error = APIError.display(error); busy = false } + private func refuse(_ error: Error) { + if let deadline = APIError.retryDeadline(error), deadline > retryAfter { + retryAfter = deadline; cooldownTask?.cancel() + let sleep = cooldownSleep + cooldownTask = Task { [weak self] in + do { + while !Task.isCancelled, deadline > Date() { + let seconds = min(3600, max(0, deadline.timeIntervalSinceNow)) + try await sleep(UInt64(seconds * 1_000_000_000)) + } + } catch { return } + guard !Task.isCancelled, let self, self.active, self.retryAfter == deadline else { return } + self.retryAfter = .distantPast + } + } + if APIError.status(error) == 401 { invalidate() } + else if APIError.status(error) == 403 { clearPrivateData(preservingBusy: true) } + } +} diff --git a/SpaceWorks/AccountViews.swift b/SpaceWorks/AccountViews.swift new file mode 100644 index 0000000..5183b15 --- /dev/null +++ b/SpaceWorks/AccountViews.swift @@ -0,0 +1,344 @@ +import SwiftUI +import SpaceWorksAPI + +struct AccountView: View { + @Bindable var state: AccountState + var privacyURL: URL? + var supportURL: URL? + #if DEBUG + var onDemoDelete: (() -> Void)? = nil + var onDemoFix: ((DemoDeletionState.Blocker) -> Void)? = nil + #endif + @Environment(\.dismiss) private var dismiss + var body: some View { + ScrollView { VStack(alignment: .leading, spacing: 24) { + Text("Account and security").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text("Manage your sign-in, devices, membership, and personal data.").foregroundStyle(Color.swMuted) + if state.isDemo { Label("Demo account · sample data", systemImage: "info.circle").foregroundStyle(Color.swMuted) } + SWCard { + NavigationLink { AccountPasswordView(state: state) } label: { AccountMenuRow(title: "Change password", detail: String(localized: "Use your current password to set a new one"), icon: "key") }.accessibilityIdentifier("account-password") + NavigationLink { AccountSecurityView(state: state) } label: { AccountMenuRow(title: "Two-step sign-in", detail: String(localized: "Authenticator and recovery codes"), icon: "lock.shield") }.accessibilityIdentifier("account-security") + NavigationLink { AccountPhoneView(state: state) } label: { AccountMenuRow(title: "Phone sign-in", detail: String(localized: "Verify or remove a phone number"), icon: "phone") }.accessibilityIdentifier("account-phone") + NavigationLink { AccountProvidersView(state: state) } label: { AccountMenuRow(title: "Connected sign-in methods", detail: String(localized: "Review your linked providers"), icon: "person.crop.circle.badge.checkmark") }.accessibilityIdentifier("account-providers") + NavigationLink { AccountDevicesView(state: state) } label: { AccountMenuRow(title: "Devices", detail: String(localized: "Review your native app sessions"), icon: "iphone") }.accessibilityIdentifier("account-devices") + if state.memberSelfServiceAvailable { + NavigationLink { AccountSessionsView(state: state) } label: { AccountMenuRow(title: "Sessions", detail: String(localized: "Review this makerspace's active sessions"), icon: "desktopcomputer.and.iphone") }.accessibilityIdentifier("account-sessions") + NavigationLink { AccountMembershipView(state: state) } label: { AccountMenuRow(title: "Your membership", detail: state.makerspaceName, icon: "person.text.rectangle") }.accessibilityIdentifier("account-membership") + } + if state.dataExportAvailable { + NavigationLink { AccountExportsView(state: state) } label: { AccountMenuRow(title: "Your data", detail: String(localized: "Request and download an export"), icon: "square.and.arrow.down") }.accessibilityIdentifier("account-exports") + } + if state.makerspaceID == nil { Text("Join a makerspace to manage membership records here.").foregroundStyle(Color.swMuted) } + } + SWCard { + Text("Privacy and help").font(.title2.bold()) + if let url = AccountLinks.safe(privacyURL) { Link("Privacy policy", destination: url).frame(minHeight: 48) } + else { Text("Your makerspace hasn't provided a privacy policy link.").foregroundStyle(Color.swMuted) } + if let url = AccountLinks.safe(supportURL) { Link("Contact support", destination: url).frame(minHeight: 48) } + else { Text("Ask your makerspace for its support contact.").foregroundStyle(Color.swMuted) } + Text("Account deletion").font(.headline) + #if DEBUG + if state.isDemo, let onDemoDelete { + NavigationLink("Preview account deletion") { + DemoDeletionView(onFix: onDemoFix.map { route in { blocker in route(blocker); dismiss() } }, onDelete: onDemoDelete) + }.frame(minHeight: 48).accessibilityIdentifier("account-delete-preview") + } else { + Text("Account deletion isn't available on this server yet. Contact your makerspace for help.").foregroundStyle(Color.swMuted) + } + #else + Text("Account deletion isn't available on this server yet. Contact your makerspace for help.").foregroundStyle(Color.swMuted) + #endif + } + }.padding(24).frame(maxWidth: 900).frame(maxWidth: .infinity) }.background(Color.swBg).foregroundStyle(Color.swInk).tint(Color.swAccentInk).navigationTitle("Account and security").navigationBarTitleDisplayMode(.inline) + } +} + +enum AccountLinks { + static func safe(_ value: URL?) -> URL? { + guard let value, value.scheme == "https", value.host?.isEmpty == false, value.user == nil, value.password == nil else { return nil }; return value + } +} + +private struct AccountMenuRow: View { + let title: LocalizedStringKey; let detail: String; let icon: String + var body: some View { + HStack(alignment: .top, spacing: 16) { + Image(systemName: icon).font(.title2).accessibilityHidden(true) + VStack(alignment: .leading, spacing: 8) { Text(title).font(.headline); Text(verbatim: detail).font(.subheadline).foregroundStyle(Color.swMuted) } + Spacer(minLength: 0); Image(systemName: "chevron.right").accessibilityHidden(true) + }.foregroundStyle(Color.swInk).padding(.vertical, 12).frame(minHeight: 64) + } +} + +private struct AccountMessages: View { + let state: AccountState + var body: some View { + if let error = state.error { SWError(message: error).accessibilityIdentifier("account-error") } + if let notice = state.notice { Label(notice, systemImage: "info.circle").foregroundStyle(Color.swMuted).accessibilityIdentifier("account-notice") } + if state.busy { ProgressView("Working…") } + if Date() < state.retryAfter { Text("Try again after \(state.retryAfter.formatted(date: .omitted, time: .standard)).").foregroundStyle(Color.swMuted) } + } +} + +private struct AccountFeedback: View { + let load: AccountState.Load; let retry: () async -> Void + var body: some View { + switch load { case .loading: ProgressView("Loading…") + case .failed(let message): SWError(message: message); Button("Try again") { Task { await retry() } }.frame(minHeight: 48) + default: EmptyView() } + } +} + +private struct AccountSecurityView: View { + @Bindable var state: AccountState + @State private var focused = false + @State private var action: String? + var body: some View { + ScrollView { VStack(alignment: .leading, spacing: 24) { + Text("Two-step sign-in").font(.largeTitle.bold()) + Text("Use an authenticator as an extra check when you sign in.").foregroundStyle(Color.swMuted) + AccountFeedback(load: state.securityLoad) { await state.loadSecurity() } + AccountMessages(state: state) + if let mfa = state.mfa { + SWCard { + Label(mfa.enabled ? "Authenticator enrolled" : "Authenticator not enrolled", systemImage: "lock.shield").font(.headline) + if mfa.required { Text("Your account requires two-step sign-in.").foregroundStyle(Color.swMuted) } + if !mfa.available && !mfa.enabled { Text("Two-step sign-in is unavailable on this server.").foregroundStyle(Color.swMuted) } + if !mfa.enabled && mfa.available && !state.isDemo { Text("Enable two-step sign-in in your makerspace's web portal, then sign in again on this device.").foregroundStyle(Color.swMuted) } + } + if mfa.enabled || state.canStartMFA { + Text("Current password").font(.headline) + if state.isDemo { Text("Use DemoPass!123 in this demo. No real security setting changes.").font(.subheadline).foregroundStyle(Color.swMuted) } + PasswordControl(title: String(localized: "Current password"), text: $state.password, identifier: "account-current-password", focus: $focused).disabled(state.busy) + Text("Changing your authenticator or recovery codes ends your existing sessions.").foregroundStyle(Color.swMuted) + if state.canStartMFA { Button("Enroll authenticator") { action = "enroll" }.buttonStyle(SWPrimary()).disabled(state.password.isEmpty) } + if mfa.enabled && mfa.available { Button("Regenerate recovery codes") { action = "recovery" }.frame(minHeight: 48).disabled(!state.canRegenerateRecovery || state.password.isEmpty).accessibilityIdentifier("account-regenerate-recovery") } + if mfa.enabled && !mfa.required { Button("Disable two-step sign-in", role: .destructive) { action = "disable" }.frame(minHeight: 48).disabled(!state.canDisableMFA || state.password.isEmpty) } + } + } + }.padding(24).frame(maxWidth: 680).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle("Two-step sign-in").navigationBarTitleDisplayMode(.inline).task { await state.loadSecurity() } + .confirmationDialog(action == "recovery" ? "Replace your recovery codes?" : action == "disable" ? "Disable two-step sign-in?" : "Enroll your authenticator?", isPresented: Binding(get: { action != nil }, set: { if !$0 { action = nil } }), titleVisibility: .visible) { + Button("Continue") { let selected = action; action = nil; Task { + if selected == "recovery" { await state.regenerateRecovery() } + else if selected == "disable" { await state.disableAuthenticator() } + else { await state.startAuthenticator() } + } } + Button("Cancel", role: .cancel) { action = nil } + } message: { Text("Your existing sessions will end after this change.") } + .onDisappear { state.password = "" } + } +} + +struct AccountSecurityCompletionView: View { + let codes: [String] + let message: String + let continueToSignIn: () -> Void + @State private var saved = false + var body: some View { + ScrollView { VStack(alignment: .leading, spacing: 24) { + Text(codes.isEmpty ? "Sign in again" : "Save your recovery codes").font(.largeTitle.bold()) + Text(message).foregroundStyle(Color.swMuted) + if !codes.isEmpty { + SWCard { Text(codes.joined(separator: "\n")).font(.body.monospaced()).textSelection(.enabled).privacySensitive() } + Text("Keep these codes somewhere safe. Each code can be used once.").foregroundStyle(Color.swMuted) + Toggle("I saved my recovery codes", isOn: $saved).frame(minHeight: 48).accessibilityIdentifier("account-recovery-saved") + } + Button("Continue to sign in", action: continueToSignIn).buttonStyle(SWPrimary()).disabled(!codes.isEmpty && !saved).accessibilityIdentifier("account-security-continue") + }.padding(24).frame(maxWidth: 600).frame(maxWidth: .infinity) }.background(Color.swBg).foregroundStyle(Color.swInk).tint(Color.swAccentInk).interactiveDismissDisabled() + } +} + +private struct AccountPhoneView: View { + @Bindable var state: AccountState + @State private var unlink = false + private enum Field: Hashable { case country, phone, code } + @FocusState private var focused: Field? + var body: some View { + ScrollView { VStack(alignment: .leading, spacing: 24) { + Text("Phone sign-in").font(.largeTitle.bold()) + Text("Verify a number to use it for sign-in.").foregroundStyle(Color.swMuted) + AccountMessages(state: state) + if let status = state.phoneStatus, status.verified { Label(status.phone_e164, systemImage: "checkmark.seal").font(.headline) } + if let pending = state.pendingPhone { + Text("Enter the code sent to \(pending)").font(.headline) + TextField("Verification code", text: $state.code).textContentType(.oneTimeCode).keyboardType(.numberPad).modifier(AccountInput()).focused($focused, equals: .code).disabled(state.busy).accessibilityIdentifier("account-phone-code") + if state.isDemo { Text("Use 123456 in this demo. No message was sent.").font(.subheadline).foregroundStyle(Color.swMuted) } + Button("Verify phone number") { Task { await state.confirmPhone() } }.buttonStyle(SWPrimary()).disabled(!state.canRequest).accessibilityIdentifier("account-phone-verify") + Button("Change number") { state.changePhoneNumber() }.frame(minHeight: 48).disabled(state.busy) + } else { + Text("Country calling code").font(.headline) + TextField("Calling code", text: $state.phoneCountry).keyboardType(.phonePad).modifier(AccountInput()).focused($focused, equals: .country).disabled(state.busy) + Text("Phone number").font(.headline) + TextField("Phone number", text: $state.phone).textContentType(.telephoneNumber).keyboardType(.phonePad).modifier(AccountInput()).focused($focused, equals: .phone).disabled(state.busy).accessibilityIdentifier("account-phone-number") + } + TimelineView(.periodic(from: .now, by: 1)) { tick in + Button(tick.date < state.resendAfter ? "Send another code in \(max(1, Int(ceil(state.resendAfter.timeIntervalSince(tick.date)))))s" : state.pendingPhone == nil ? "Send verification code" : "Send another code") { Task { await state.sendPhoneCode() } } + .frame(minHeight: 48).disabled(!state.canRequest || tick.date < state.resendAfter).accessibilityIdentifier("account-phone-send") + } + Button("Remove linked phone", role: .destructive) { unlink = true }.frame(minHeight: 48).disabled(!state.canRequest) + }.padding(24).frame(maxWidth: 680).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle("Phone sign-in").navigationBarTitleDisplayMode(.inline).scrollDismissesKeyboard(.interactively) + .toolbar { ToolbarItemGroup(placement: .keyboard) { Spacer(); Button("Done") { focused = nil } } } + .confirmationDialog("Remove phone sign-in?", isPresented: $unlink, titleVisibility: .visible) { Button("Remove phone", role: .destructive) { Task { await state.unlinkPhone() } }; Button("Cancel", role: .cancel) {} } + .onDisappear { state.code = "" } + } +} + +private struct AccountProvidersView: View { + @Bindable var state: AccountState + @State private var removing: String? + var body: some View { + ScrollView { VStack(alignment: .leading, spacing: 24) { + Text("Connected sign-in methods").font(.largeTitle.bold()) + AccountFeedback(load: state.providersLoad) { await state.loadProviders() }; AccountMessages(state: state) + ForEach(state.providers, id: \.provider) { provider in + SWCard { + Text(NativeStatusCopy.label(provider.provider)).font(.headline) + Text("Connected \(provider.created_at.formatted(date: .abbreviated, time: .omitted))").foregroundStyle(Color.swMuted) + Button("Remove sign-in method", role: .destructive) { removing = provider.provider }.frame(minHeight: 48).disabled(!state.canRequest) + } + } + if state.providersLoad == .ready && state.providers.isEmpty { Text("No connected providers.").foregroundStyle(Color.swMuted) } + Text("Connect a new provider in your makerspace's web portal. Your existing sign-in methods remain available here.").foregroundStyle(Color.swMuted) + #if DEBUG + if state.isDemo { DemoAccountProviderLinks(state: state) } + #endif + }.padding(24).frame(maxWidth: 680).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle("Sign-in methods").navigationBarTitleDisplayMode(.inline).task { await state.loadProviders() } + .confirmationDialog("Remove this sign-in method?", isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }), titleVisibility: .visible) { + if let provider = removing { Button("Remove", role: .destructive) { removing = nil; Task { await state.unlinkProvider(provider) } } } + Button("Cancel", role: .cancel) { removing = nil } + } message: { Text("Make sure you can still sign in another way. Your makerspace may refuse to remove your last method.") } + } +} + +private struct AccountSessionsView: View { + @Bindable var state: AccountState + @State private var session: Components.Schemas.AccountSession? + var body: some View { + ScrollView { LazyVStack(alignment: .leading, spacing: 24) { + Text("Sessions").font(.largeTitle.bold()); AccountMessages(state: state) + AccountFeedback(load: state.sessionsLoad) { await state.loadSessions() } + ForEach(state.sessions, id: \.id) { row in + SWCard { + Label(row.kind.rawValue == "device" ? "Device session" : "Browser session", systemImage: row.kind.rawValue == "device" ? "iphone" : "globe").font(.headline) + if row.current { Text("This session").foregroundStyle(Color.swAccentInk).font(.headline) } + Text("Started \(row.created_at.formatted(date: .abbreviated, time: .shortened))").foregroundStyle(Color.swMuted) + if let last = row.last_used_at { Text("Last used \(last.formatted(date: .abbreviated, time: .shortened))").foregroundStyle(Color.swMuted) } + Button(row.current ? "Sign out this session" : "Sign out session", role: .destructive) { session = row }.frame(minHeight: 48).disabled(!state.canRequest).accessibilityIdentifier(row.current ? "account-revoke-current-session" : "account-revoke-session-\(row.id)") + } + } + if state.sessionsHaveMore { Text("More sessions are available in your makerspace's web portal.").foregroundStyle(Color.swMuted) } + }.padding(24).frame(maxWidth: 900).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle("Sessions").navigationBarTitleDisplayMode(.inline) + .task { await state.loadSessions() }.refreshable { await state.loadSessions() } + .confirmationDialog("Sign out this session?", isPresented: Binding(get: { session != nil }, set: { if !$0 { session = nil } }), titleVisibility: .visible) { + if let row = session { Button("Sign out", role: .destructive) { session = nil; Task { await state.revokeSession(row) } } }; Button("Cancel", role: .cancel) { session = nil } + } + + } +} + +private struct AccountDevicesView: View { + @Bindable var state: AccountState + @State private var grant: Components.Schemas.DeviceGrant? + var body: some View { + ScrollView { LazyVStack(alignment: .leading, spacing: 24) { + Text("Devices").font(.largeTitle.bold()) + Text("These are native app sessions on your account. Browser sessions are managed separately by your makerspace.").foregroundStyle(Color.swMuted) + AccountMessages(state: state) + Text("Registered devices").font(.title2.bold()) + AccountFeedback(load: state.grantsLoad) { await state.loadGrants() } + ForEach(state.grants, id: \.id) { row in + SWCard { + Text(NativeStatusCopy.label(row.platform)).font(.headline) + if state.currentDeviceGrantID == row.id { Text("This device").font(.headline).foregroundStyle(Color.swAccentInk) } + Text(NativeStatusCopy.label(row.status)); Text("Last used \(row.last_used_at.formatted(date: .abbreviated, time: .shortened))").foregroundStyle(Color.swMuted) + Button("Sign out device", role: .destructive) { grant = row }.frame(minHeight: 48).disabled(!state.canRequest || row.status != "active").accessibilityIdentifier("account-revoke-device-\(row.id)") + } + } + + if state.grantsLoad == .ready && state.grants.isEmpty { Text("No devices found.").foregroundStyle(Color.swMuted) } + }.padding(24).frame(maxWidth: 900).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle("Devices").navigationBarTitleDisplayMode(.inline) + .task { await state.loadGrants() }.refreshable { await state.loadGrants() } + .confirmationDialog("Sign out this device?", isPresented: Binding(get: { grant != nil }, set: { if !$0 { grant = nil } }), titleVisibility: .visible) { + if let row = grant { Button("Sign out device", role: .destructive) { grant = nil; Task { await state.revokeGrant(row) } } }; Button("Cancel", role: .cancel) { grant = nil } + } + } +} + +private struct AccountMembershipView: View { + @Bindable var state: AccountState + @State private var decision: String? + @FocusState private var reasonFocused: Bool + var body: some View { + ScrollView { VStack(alignment: .leading, spacing: 24) { + Text(state.makerspaceName).font(.largeTitle.bold()); AccountMessages(state: state) + AccountFeedback(load: state.membershipLoad) { await state.loadMembership() } + if let membership = state.membership { + Text(NativeStatusCopy.label(membership.status.rawValue)).font(.headline) + if let date = membership.renewal_cancelled_at { Text("Renewal cancelled \(date.formatted(date: .abbreviated, time: .omitted))").foregroundStyle(Color.swMuted) } + if membership.status.rawValue == "active" { + Text("Reason").font(.headline) + TextField("Tell your makerspace why", text: $state.membershipReason, axis: .vertical).focused($reasonFocused).lineLimit(3...6).modifier(AccountInput()).disabled(state.busy).accessibilityIdentifier("account-membership-reason") + Text("Leaving ends your access to this makerspace. Outstanding loans or charges may need to be resolved first.").foregroundStyle(Color.swMuted) + if membership.renewal_cancelled_at == nil { Button("Cancel membership renewal") { decision = "renewal" }.frame(minHeight: 48).disabled(!state.canRequest || state.membershipReason.isEmpty) } + Button("Leave makerspace", role: .destructive) { decision = "leave" }.frame(minHeight: 48).disabled(!state.canRequest || state.membershipReason.isEmpty).accessibilityIdentifier("account-leave-makerspace") + } + } + }.padding(24).frame(maxWidth: 680).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle("Your membership").navigationBarTitleDisplayMode(.inline).scrollDismissesKeyboard(.interactively) + .toolbar { ToolbarItemGroup(placement: .keyboard) { Spacer(); Button("Done") { reasonFocused = false } } }.task { await state.loadMembership() } + .confirmationDialog(decision == "leave" ? "Leave this makerspace?" : "Cancel membership renewal?", isPresented: Binding(get: { decision != nil }, set: { if !$0 { decision = nil } }), titleVisibility: .visible) { + Button(decision == "leave" ? "Confirm leave" : "Confirm cancellation", role: .destructive) { let leave = decision == "leave"; decision = nil; Task { if leave { await state.leaveMakerspace() } else { await state.cancelRenewal() } } }; Button("Keep membership", role: .cancel) { decision = nil } + } + } +} + +private struct AccountInput: ViewModifier { + func body(content: Content) -> some View { + content.padding(16).frame(minHeight: 48).background(Color.swPanel, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)).foregroundStyle(Color.swInk) + } +} + +private struct AccountExportsView: View { + @Bindable var state: AccountState + @State private var share = false + var body: some View { + ScrollView { LazyVStack(alignment: .leading, spacing: 24) { + Text("Your data").font(.largeTitle.bold()) + Text("Request an export of your personal data held by this makerspace. Keep downloaded files somewhere private.").foregroundStyle(Color.swMuted) + AccountMessages(state: state); AccountFeedback(load: state.exportsLoad) { await state.loadExports() } + Button("Request data export") { Task { await state.requestExport() } }.buttonStyle(SWPrimary()).disabled(!state.canRequest).accessibilityIdentifier("account-request-export") + if state.downloading { ProgressView("Preparing your archive…") } + ForEach(state.exports, id: \.id) { row in + SWCard { + Text(row.created_at, format: .dateTime.day().month().year()).font(.headline) + Text(NativeStatusCopy.label(row.status.value1.rawValue)).foregroundStyle(Color.swMuted) + if !row.failure_detail.isEmpty { SWError(message: row.failure_detail) } + TimelineView(.periodic(from: .now, by: 1)) { tick in + if state.canDownloadExport(row, at: tick.date) { + Text("Available until \(row.expires_at.formatted(date: .abbreviated, time: .shortened))").foregroundStyle(Color.swMuted) + Button("Download export") { Task { await state.downloadExport(row) } }.frame(minHeight: 48).disabled(!state.canRequest || state.downloading).accessibilityIdentifier("account-download-export") + } else if ["pending", "running"].contains(row.status.value1.rawValue) { + Button("Check export status") { Task { await state.refreshExport(row) } }.frame(minHeight: 48).disabled(!state.canRequest).accessibilityIdentifier("account-check-export") + } else if row.expires_at <= tick.date { Text("This export expired. Request a new archive.").foregroundStyle(Color.swMuted).accessibilityIdentifier("account-export-expired") } + } + } + } + if state.exportsHaveMore { Text("More exports are available in your makerspace's web portal.").foregroundStyle(Color.swMuted) } + }.padding(24).frame(maxWidth: 900).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle("Your data").navigationBarTitleDisplayMode(.inline).task { await state.loadExports() }.refreshable { await state.loadExports() } + .onChange(of: state.archive?.id) { _, _ in share = false } + .sheet(item: $state.archive) { file in + NavigationStack { + ScrollView { + VStack(spacing: 24) { + Image(systemName: "doc.zipper").font(.largeTitle).accessibilityHidden(true) + Text(state.isDemo ? "Demo data export" : "Your data export").font(.title.bold()) + Text("Save this archive to a private location or share it with someone you trust.").foregroundStyle(Color.swMuted) + Button("Save or share archive") { share = true }.buttonStyle(SWPrimary()).accessibilityIdentifier("account-share-export") + }.padding(24).frame(maxWidth: 640).frame(maxWidth: .infinity) + }.background(Color.swBg).accessibilityIdentifier("account-export-scroll") + .navigationTitle("Data export").toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { state.archive = nil } } } + .sheet(isPresented: $share) { NativeDocumentShare(file: file) } + } + } + } +} diff --git a/SpaceWorks/AuthState.swift b/SpaceWorks/AuthState.swift index cd6c131..0aeac79 100644 --- a/SpaceWorks/AuthState.swift +++ b/SpaceWorks/AuthState.swift @@ -4,7 +4,7 @@ import SpaceWorksAPI @MainActor @Observable final class AuthState { - enum Stage: Equatable { case restoring, signIn, mfa, recoveryCodes, passwordChange, signedIn } + enum Stage: Equatable { case restoring, signIn, mfa, recoveryCodes, passwordChange, securityCompletion, signedIn } private(set) var stage: Stage = .restoring var username = "" var password = "" @@ -17,6 +17,8 @@ final class AuthState { private(set) var mfa: Components.Schemas.MFAChallengeResponse? private(set) var enrollment: Components.Schemas.MFAEnrollment? private(set) var recoveryCodes: [String] = [] + private(set) var securityMessage: String? + private var locallyLeftMemberships = Set() private(set) var selectedID: Int? private(set) var configuration: Components.Schemas.PublicConfig? let service: SpaceWorksService @@ -57,7 +59,7 @@ final class AuthState { } var memberships: [Components.Schemas.AuthMembership] { // Organization-derived authority is excluded by the mobile scope. /me supplies active memberships. - user?.makerspaces.filter { $0.source == .membership } ?? [] + user?.makerspaces.filter { $0.source == .membership && !locallyLeftMemberships.contains($0.id) } ?? [] } var selectedMembership: Components.Schemas.AuthMembership? { memberships.first { $0.id == selectedID } } var displayName: String { user?.display_name.isEmpty == false ? user!.display_name : user?.username ?? "" } @@ -83,17 +85,54 @@ final class AuthState { } func accountForm(_ step: AccountAccessState.Step) -> AccountAccessState { let service = service + let generation = identityGeneration + let accountID = user?.id + let credentials = acceptedCredentials let task = step == .signUp ? "signup" : step == .emailVerification ? "email" : "password-recovery" - let form = AccountAccessState(step: step, cooldown: AccountAccessCooldown.scoped(serverScope + "." + task), + let scope = serverScope + "." + task + (step == .emailVerification ? ".\(accountID ?? 0)" : "") + let form = AccountAccessState(step: step, cooldown: AccountAccessCooldown.scoped(scope), create: { try await service.createAccount(name: $0, email: $1, password: $2, phone: $3) }, request: { try await service.requestPasswordReset(email: $0) }, reset: { try await service.resetPassword(email: $0, code: $1, new: $2) }, - verify: { [weak self] code in try await service.confirmEmail(code: code); await self?.foreground() }, - resendVerification: { try await service.resendEmailVerification() }) + verify: { [weak self] code in + guard let self else { throw CancellationError() } + try await self.emailOperation(generation: generation, accountID: accountID, credentials: credentials) { + try await service.confirmEmail(code: code) + } + await self.foreground() + }, + resendVerification: { [weak self] in + guard let self else { throw CancellationError() } + try await self.emailOperation(generation: generation, accountID: accountID, credentials: credentials) { + try await service.resendEmailVerification() + } + }, ownerIsCurrent: { [weak self] in + guard step == .emailVerification else { return true } + guard let self, let accountID else { return false } + return self.identityGeneration == generation && self.user?.id == accountID && self.stage == .signedIn + }) form.email = user?.email ?? username return form } + private func emailOperation(generation: Int, accountID: Int?, credentials: CredentialSnapshot?, + operation: @Sendable () async throws -> Void) async throws { + guard let accountID, let credentials, + identityGeneration == generation, user?.id == accountID, stage == .signedIn else { throw CancellationError() } + do { try await service.withSession(expectedCredentials: credentials, operation: operation) } + catch { + let hasNoCredentials = await service.refresh.current() == nil + let sameInstallation = await service.refresh.isSameInstallation(credentials) + guard !APIError.isCancellation(error), sameInstallation, + identityGeneration == generation, user?.id == accountID, stage == .signedIn else { throw CancellationError() } + if APIError.status(error) == 401 || hasNoCredentials { await invalidateSession(message: APIError.display(error)) } + else if APIError.status(error) == 403 { await foreground() } + throw error + } + guard await service.refresh.isCurrent(credentials), + identityGeneration == generation, user?.id == accountID, stage == .signedIn else { throw CancellationError() } + } + func restore() async { guard !restored else { return } restored = true @@ -111,7 +150,7 @@ final class AuthState { func signIn() async { guard !busy, (retryAfter ?? .distantPast) <= Date() else { return } guard !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !password.isEmpty else { - error = "Enter your email or username and password."; return + error = String(localized: "Enter your email or username and password."); return } busy = true; error = nil defer { busy = false; password = "" } @@ -119,7 +158,7 @@ final class AuthState { let challenge = try await service.challenge(appID: Brand.bundleID, development: NativeAttestor.development) let expires = Date().addingTimeInterval(TimeInterval(challenge.expires_in)) let proof = try await attest(challenge.challenge) - guard Date() < expires else { throw APIError(status: 0, code: "challenge_expired", detail: "Your sign-in expired. Try again.") } + guard Date() < expires else { throw APIError(status: 0, code: "challenge_expired", detail: String(localized: "Your sign-in expired. Try again.")) } let result = try await service.signIn(appID: Brand.bundleID, development: NativeAttestor.development, username: username.trimmingCharacters(in: .whitespacesAndNewlines), password: password, challenge: challenge.challenge, attestationJSON: proof) @@ -137,9 +176,9 @@ final class AuthState { func verify() async { guard !busy, let mfa, (retryAfter ?? .distantPast) <= Date() else { return } guard (mfaExpires ?? .distantPast) > Date() else { - backToSignIn(); error = "Your sign-in expired. Try again."; return + backToSignIn(); error = String(localized: "Your sign-in expired. Try again."); return } - guard code.count >= 6 else { error = "Enter your authenticator or recovery code."; return } + guard code.count >= 6 else { error = String(localized: "Enter your authenticator or recovery code."); return } busy = true; error = nil defer { busy = false; code = "" } do { @@ -148,7 +187,7 @@ final class AuthState { let grant = grantData.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } guard let refresh = result.refresh, !refresh.isEmpty, !result.access.isEmpty, result.surface != "verification_only", let grantID = grant?["id"] as? String, UUID(uuidString: grantID) != nil else { - throw APIError(status: 0, code: "native_session_missing", detail: "This server did not provide a device session. Contact your makerspace.") + throw APIError(status: 0, code: "native_session_missing", detail: String(localized: "This server did not provide a device session. Contact your makerspace.")) } recoveryCodes = result.recovery_codes ?? [] try await accept(TokenPair(access: result.access, refresh: refresh)) @@ -203,28 +242,107 @@ final class AuthState { do { try await service.refresh.install(nil) await clear() - if revocationFailed { error = "Signed out on this device. Server revocation could not be confirmed." } + if revocationFailed { error = String(localized: "Signed out on this device. Server revocation could not be confirmed.") } } catch { await clear(); fail(error) } } func changePassword(current: String, new: String, confirmation: String) async { - guard !busy, (retryAfter ?? .distantPast) <= Date() else { return } + guard stage == .passwordChange, !busy, (retryAfter ?? .distantPast) <= Date(), let accountID = user?.id, let credentials = acceptedCredentials else { return } guard !current.isEmpty, !new.isEmpty, new == confirmation else { - error = "Enter your current password and matching new passwords."; return + error = String(localized: "Enter your current password and matching new passwords."); return } + let generation = identityGeneration busy = true; error = nil defer { busy = false } + let repository = LiveAccountRepository(service: service, expectedCredentials: credentials, makerspaceID: nil, origin: origin, + onUnauthorized: { [weak self] message in await self?.accountRefused(message, accountID: accountID, generation: generation) }, + onForbidden: { [weak self] in await self?.accountAuthorityChanged(accountID: accountID, generation: generation) }) do { - try await service.changePassword(current: current, new: new) - try await loadIdentity() - if stage == .passwordChange { error = "Your account still requires a password update. Try again or contact your makerspace." } + try await repository.updateTemporaryPassword(current: current, new: new) + guard identityGeneration == generation, stage == .passwordChange, user?.id == accountID else { return } + // The required temporary-password gate always returns to native authentication. + // Release busy synchronously so signOut can immediately claim teardown ownership. + busy = false; await signOut() + if stage == .signIn, user == nil { error = String(localized: "Password updated. Sign in again.") } } catch { + guard identityGeneration == generation else { return } + fail(error) + } + } + /// Global security works before joining a makerspace; membership records remain bootstrap-gated. + func accountState(makerspaceID: Int?, selfService: Bool = false, exports: Bool = false) -> AccountState? { + guard let accountID = user?.id, let credentials = acceptedCredentials, stage == .signedIn, + makerspaceID == nil || memberships.contains(where: { $0.id == makerspaceID }) else { return nil } + let generation = identityGeneration + let name = memberships.first(where: { $0.id == makerspaceID })?.slug ?? "" + let repository = LiveAccountRepository(service: service, expectedCredentials: credentials, makerspaceID: makerspaceID, origin: origin, + onUnauthorized: { [weak self] message in await self?.accountRefused(message, accountID: accountID, generation: generation) }, + onForbidden: { [weak self] in await self?.accountAuthorityChanged(accountID: accountID, generation: generation) }) + return AccountState(repository: repository, makerspaceID: makerspaceID, makerspaceName: name, + selfServiceEnabled: selfService, exportEnabled: exports, + phoneCooldown: AccountAccessCooldown.scoped(serverScope + ".account.\(accountID).phone-link"), + endSession: { [weak self] codes, message in + guard let self, self.identityGeneration == generation, self.user?.id == accountID else { return } + await self.securitySessionEnded(codes: codes, message: message) + }, + membershipEnded: { [weak self] in guard let self, self.identityGeneration == generation, self.user?.id == accountID, let makerspaceID else { return }; await self.confirmedMembershipLeave(makerspaceID) }, + passwordChanged: { [weak self] in guard let self, self.identityGeneration == generation, self.user?.id == accountID else { return }; await self.foreground() }, + beforeLeave: { [weak self] in + guard let self, self.identityGeneration == generation, let makerspaceID, self.user?.id == accountID else { throw CancellationError() } + let ids = try await self.freshMembershipIDs() + guard ids.contains(makerspaceID), self.user?.id == accountID else { throw CancellationError() } + if let notifications = self.notifications { + guard await notifications.registration.removeMembership(makerspaceID, authorizedDirectMembershipIDs: ids) else { + throw APIError(status: notifications.registration.retryAfter > Date() ? 429 : 0, + code: "membership_notification_cleanup", detail: notifications.registration.error ?? String(localized: "Device notifications could not be updated. Try again before leaving."), + retryAfter: notifications.registration.retryAfter > Date() ? notifications.registration.retryAfter : nil) + } + } + guard self.user?.id == accountID, self.stage == .signedIn else { throw CancellationError() } + }, leaveFailed: { [weak self] in + guard let self, self.identityGeneration == generation, let makerspaceID, self.user?.id == accountID else { return } + if let ids = try? await self.freshMembershipIDs(), ids.contains(makerspaceID), self.user?.id == accountID { + await self.notifications?.registration.resumeMembership(makerspaceID, authorizedDirectMembershipIDs: ids) + } + }) + } + private func accountRefused(_ message: String, accountID: Int, generation: Int) async { + guard identityGeneration == generation, user?.id == accountID else { return } + await invalidateSession(message: message) + } + private func accountAuthorityChanged(accountID: Int, generation: Int) async { + guard identityGeneration == generation, user?.id == accountID else { return } + await foreground() + } + func securitySessionEnded(codes: [String], message: String?) async { + await invalidateSession(message: message ?? String(localized: "Sign in again to continue.")) + guard user == nil, stage == .signIn, !endingSession else { return } + recoveryCodes = codes; securityMessage = message ?? String(localized: "Your security settings changed. Sign in again.") + stage = .securityCompletion + } + func finishSecurityCompletion() { recoveryCodes = []; securityMessage = nil; stage = .signIn } + /// A confirmed server leave revokes local scope immediately, even when the following /me is offline. + /// Ignore stale membership echoes until a fresh native sign-in establishes a new account session. + func confirmedMembershipLeave(_ id: Int) async { + guard stage == .signedIn, memberships.contains(where: { $0.id == id }) else { return } + let accountID = user?.id + locallyLeftMemberships.insert(id); clearPrivateHolders() + identityGeneration += 1; identityFlight?.cancel(); identityFlight = nil; busy = false + settings.removeObject(forKey: selectionKey) + user?.makerspaces.removeAll { $0.id == id } + if selectedID == id { selectedID = nil } + let ids = Set(memberships.map(\.id)) + try? await service.makerspace.select(selectedID, activeMemberships: ids) + guard user?.id == accountID, stage == .signedIn else { return } + do { try await refreshedIdentity() } + catch { + guard user?.id == accountID, stage == .signedIn else { return } fail(error) if isUnauthorized(error) { await invalidateSession(message: APIError.display(error)) } } } private func accept(_ pair: TokenPair) async throws { guard !pair.access.isEmpty, !pair.refresh.isEmpty else { - throw APIError(status: 0, code: "native_session_missing", detail: "This server did not provide a device session. Contact your makerspace.") + throw APIError(status: 0, code: "native_session_missing", detail: String(localized: "This server did not provide a device session. Contact your makerspace.")) } try await service.refresh.install(pair) do { try await loadIdentity() } catch { @@ -236,12 +354,13 @@ final class AuthState { private func loadIdentity() async throws { let generation = identityGeneration let credentials = await service.refresh.snapshot() - let identity = try await service.me() + var identity = try await service.me() guard generation == identityGeneration, await service.refresh.isCurrent(credentials) else { throw CancellationError() } guard !identity.is_superuser else { await invalidateSession(message: "Use the web console") - throw APIError(status: 403, code: "device_superadmin_not_supported", detail: "Use the web console") + throw APIError(status: 403, code: "device_superadmin_not_supported", detail: String(localized: "Use the web console")) } + identity.makerspaces.removeAll { locallyLeftMemberships.contains($0.id) } let direct = identity.makerspaces.filter { $0.source == .membership } let ids = Set(direct.map(\.id)) let previous = user?.id == identity.id ? selectedID : nil @@ -277,7 +396,7 @@ final class AuthState { } /// Routing and registration require a successful current /me, never the last cached identity. func freshMembershipIDs() async throws -> Set { - if let deadline = retryAfter, deadline > Date() { throw APIError(status: 429, code: "identity_throttled", detail: error ?? "Too many requests. Try again later.", retryAfter: deadline) } + if let deadline = retryAfter, deadline > Date() { throw APIError(status: 429, code: "identity_throttled", detail: error ?? String(localized: "Too many requests. Try again later."), retryAfter: deadline) } guard stage == .signedIn, !endingSession, let accountID = user?.id else { throw CancellationError() } let generation = identityGeneration do { @@ -372,7 +491,7 @@ final class AuthState { private func clearLocalIdentity() { identityGeneration += 1; identityFlight?.cancel(); identityFlight = nil; clearPrivateHolders() settings.removeObject(forKey: selectionKey) - acceptedCredentials = nil + locallyLeftMemberships = []; securityMessage = nil; acceptedCredentials = nil user = nil; selectedID = nil; mfa = nil; mfaExpires = nil; enrollment = nil; recoveryCodes = [] username = ""; password = ""; code = ""; stage = .signIn } diff --git a/SpaceWorks/AuthView.swift b/SpaceWorks/AuthView.swift index e488e6b..693eb53 100644 --- a/SpaceWorks/AuthView.swift +++ b/SpaceWorks/AuthView.swift @@ -24,8 +24,10 @@ struct AuthRoot: View { case .signIn: SignInView(auth: auth, state: state) case .mfa, .recoveryCodes: MFAView(auth: auth) case .passwordChange: PasswordChangeView(auth: auth) + case .securityCompletion: + AccountSecurityCompletionView(codes: auth.recoveryCodes, message: auth.securityMessage ?? "", continueToSignIn: auth.finishSecurityCompletion) case .signedIn: - if auth.selectedID == nil { MakerspacePicker(auth: auth) } + if auth.selectedID == nil { AccountMakerspaceLanding(auth: auth) } else { AppShell(state: state, auth: auth, linkRouter: linkRouter) } } } @@ -44,7 +46,7 @@ struct AuthRoot: View { linkRouter.bind(account: origin.url.absoluteString + ".account.\(id)", refresh: { try await auth.freshMembershipIDs() }, select: { try await auth.selectForLink($0) }, resolveLegacy: { legacy in _ = try await auth.freshMembershipIDs() guard let membership = auth.memberships.first(where: { $0.slug == legacy.slug }), !legacy.staffInbox || auth.staffToolsAvailable else { - throw APIError(status: 403, code: "link_membership_unavailable", detail: "This update isn't available to your account.") + throw APIError(status: 403, code: "link_membership_unavailable", detail: String(localized: "This update isn't available to your account.")) } return MobileLink(makerspaceID: membership.id, destination: legacy.staffInbox ? .inbox : .home) }, owner: routingOwner) @@ -63,16 +65,16 @@ struct PasswordChangeView: View { SWCard { Text("Choose a new password").font(.largeTitle.bold()) Text("Replace your temporary password to continue.").foregroundStyle(Color.swMuted) - PasswordControl(title: "Current password", text: $current, identifier: "current-password") - PasswordControl(title: "New password", text: $new, newPassword: true, identifier: "new-password") - PasswordControl(title: "Confirm password", text: $confirmation, newPassword: true, identifier: "confirm-password") + PasswordControl(title: String(localized: "Current password"), text: $current, identifier: "current-password") + PasswordControl(title: String(localized: "New password"), text: $new, newPassword: true, identifier: "new-password") + PasswordControl(title: String(localized: "Confirm password"), text: $confirmation, newPassword: true, identifier: "confirm-password") if let error = auth.error { SWError(message: error) } Button(auth.busy ? "Updating…" : "Update password") { Task { await auth.changePassword(current: current, new: new, confirmation: confirmation); current = ""; new = ""; confirmation = "" } }.buttonStyle(SWPrimary()).disabled(auth.busy) Button("Sign out") { Task { await auth.signOut() } }.frame(minHeight: 48).disabled(auth.busy) }.padding(24).frame(maxWidth: 560).frame(maxWidth: .infinity) - }.background(Color.swBg) + }.background(Color.swBg).onDisappear { current = ""; new = ""; confirmation = "" } } } @@ -242,7 +244,7 @@ struct MFAView: View { } struct QRCodeView: View { let value: String - var label = "Authenticator setup QR code" + var label = String(localized: "Authenticator setup QR code") var body: some View { if let image = image { Image(uiImage: image).interpolation(.none).resizable().scaledToFit().frame(maxWidth: 240, maxHeight: 240) @@ -259,6 +261,8 @@ struct QRCodeView: View { } struct MakerspacePicker: View { let auth: AuthState + var account: AccountState? = nil + @State private var emailForm: AccountAccessState? var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { @@ -274,9 +278,15 @@ struct MakerspacePicker: View { } }.buttonStyle(.plain).accessibilityIdentifier("makerspace-\(membership.id)") } + if let account { + NavigationLink("Account and security") { AccountView(state: account, privacyURL: Brand.privacyURL.flatMap(URL.init(string:)), supportURL: Brand.supportURL.flatMap(URL.init(string:))) } + .frame(minHeight: 48).accessibilityIdentifier("account-entry") + } + if auth.user?.email_verified == false { Button("Confirm email") { emailForm = auth.accountForm(.emailVerification) }.frame(minHeight: 48) } if let error = auth.error { SWError(message: error) } - Button("Sign out") { Task { await auth.signOut() } }.frame(minHeight: 48) + Button("Sign out") { Task { await auth.signOut() } }.frame(minHeight: 48).disabled(auth.busy) }.padding(24).frame(maxWidth: 640).frame(maxWidth: .infinity) }.background(Color.swBg) + .sheet(item: $emailForm) { form in AccountAccessView(form: form) { emailForm = nil } } } } diff --git a/SpaceWorks/BookingState.swift b/SpaceWorks/BookingState.swift index 36a4fda..ad3e695 100644 --- a/SpaceWorks/BookingState.swift +++ b/SpaceWorks/BookingState.swift @@ -17,7 +17,7 @@ final class BookingState { private var active = true init(repository: any MemberActionsRepository, space: Components.Schemas.PublicBookableSpace) { self.repository = repository; self.space = space } private func valid(now: Date) -> Bool { - guard end > now, end > start else { error = "Choose an end time in the future and after the start time."; return false } + guard end > now, end > start else { error = String(localized: "Choose an end time in the future and after the start time."); return false } return true } func check(now: Date = Date()) async { diff --git a/SpaceWorks/BookingViews.swift b/SpaceWorks/BookingViews.swift index 7804de8..42dd22a 100644 --- a/SpaceWorks/BookingViews.swift +++ b/SpaceWorks/BookingViews.swift @@ -95,14 +95,14 @@ struct SpaceBookingView: View { } private func book() async { formError = nil - guard let answers = form.validatedAnswers() else { formError = "Check the highlighted questions."; return } + guard let answers = form.validatedAnswers() else { formError = String(localized: "Check the highlighted questions."); return } do { let format = ISO8601DateFormatter() let body: [String: Any] = ["starts_at": format.string(from: state.start), "ends_at": format.string(from: state.end), "custom_answers": answers.isEmpty ? NSNull() : answers] let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 let input = try decoder.decode(Components.Schemas.PublicBookingInput.self, from: JSONSerialization.data(withJSONObject: body)) await state.confirm(input: input); if state.result != nil { await completed() } - } catch { formError = "These booking details could not be prepared. Check your answers and try again." } + } catch { formError = String(localized: "These booking details could not be prepared. Check your answers and try again.") } } } @@ -131,7 +131,7 @@ struct MemberBookingDetailView: View { Text(current.space_name).font(.largeTitle.bold()) Text(intervalLabel(current.starts_at, current.ends_at, timezone: current.timezone)) Text(current.timezone).font(.caption).foregroundStyle(Color.swMuted) - Text(current.status.value1.rawValue.replacingOccurrences(of: "_", with: " ").capitalized).font(.headline) + Text(NativeStatusCopy.label(current.status.value1.rawValue)).font(.headline) Text("Booking reference").font(.headline); Text(current.public_token).font(.body.monospaced()).textSelection(.enabled) if let error { SWError(message: error) } if current.can_cancel { diff --git a/SpaceWorks/DemoAccountLandingView.swift b/SpaceWorks/DemoAccountLandingView.swift new file mode 100644 index 0000000..a086aeb --- /dev/null +++ b/SpaceWorks/DemoAccountLandingView.swift @@ -0,0 +1,28 @@ +#if DEBUG +import SwiftUI + +/// A native account remains useful before joining a makerspace. +struct DemoAccountLandingView: View { + let session: DemoSession + @State private var account: AccountState + init(session: DemoSession) { self.session = session; _account = State(initialValue: session.accountState(makerspaceID: nil)) } + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + BrandWordmark() + Text("Choose a makerspace").font(.largeTitle.bold()) + Text("You haven’t joined a makerspace yet.").foregroundStyle(Color.swMuted) + SWCard { + Text(session.name).font(.title2.bold()) + NavigationLink("Account and security") { + AccountView(state: account, privacyURL: Brand.privacyURL.flatMap(URL.init(string:)), supportURL: Brand.supportURL.flatMap(URL.init(string:)), onDemoDelete: session.reset) + }.frame(minHeight: 48).accessibilityIdentifier("account-entry") + Button("Sign out") { account.invalidate(); session.reset() }.frame(minHeight: 48) + } + }.padding(24).frame(maxWidth: 640).frame(maxWidth: .infinity) + }.background(Color.swBg).navigationTitle("Your makerspaces").navigationBarTitleDisplayMode(.inline) + }.onDisappear { account.invalidate() } + } +} +#endif diff --git a/SpaceWorks/DemoAccountScope.swift b/SpaceWorks/DemoAccountScope.swift new file mode 100644 index 0000000..a3a8fd7 --- /dev/null +++ b/SpaceWorks/DemoAccountScope.swift @@ -0,0 +1,76 @@ +#if DEBUG +import Foundation +import SpaceWorksAPI + +/// Global sign-in methods follow the demo account; member records remain makerspace-specific. +struct DemoScopedAccountRepository: DemoAccountLinking { + let global: DemoAccountRepository + let member: DemoAccountRepository + let isDemo = true + func grants() async throws -> [Components.Schemas.DeviceGrant] { try await global.grants() } + func revokeGrant(id: String) async throws -> Void { try await global.revokeGrant(id: id) } + func sessions(page: Int) async throws -> Components.Schemas.AccountSessionPage { try await member.sessions(page: page) } + func revokeSession(id: String) async throws -> Void { try await member.revokeSession(id: id) } + func membership() async throws -> Components.Schemas.MembershipAccount { try await member.membership() } + func leave(reason: String) async throws -> Components.Schemas.MembershipAccount { try await member.leave(reason: reason) } + func cancelRenewal(reason: String) async throws -> Components.Schemas.MembershipAccount { try await member.cancelRenewal(reason: reason) } + func exports(page: Int) async throws -> Components.Schemas.SubjectExportList { try await member.exports(page: page) } + func requestExport() async throws -> Components.Schemas.SubjectExport { try await member.requestExport() } + func export(id: String) async throws -> Components.Schemas.SubjectExport { try await member.export(id: id) } + func exportDownload(id: String) async throws -> Components.Schemas.DataExportDownloadUrl { try await member.exportDownload(id: id) } + func exportArchive(id: String) async throws -> Data { try await member.exportArchive(id: id) } + func mfa() async throws -> Components.Schemas.MFAStatus { try await global.mfa() } + func startMFA(password: String) async throws -> Components.Schemas.MFAChallengeResponse { try await global.startMFA(password: password) } + func disableMFA(password: String) async throws -> Components.Schemas.MFAStatus { try await global.disableMFA(password: password) } + func regenerateRecovery(password: String) async throws -> Components.Schemas.MFARecovery { try await global.regenerateRecovery(password: password) } + func startPhone(phone: String) async throws -> Components.Schemas.PhoneStartResponse { try await global.startPhone(phone: phone) } + func confirmPhone(phone: String, code: String) async throws -> Components.Schemas.PhoneStatus { try await global.confirmPhone(phone: phone, code: code) } + func unlinkPhone() async throws -> Components.Schemas.PhoneStatus { try await global.unlinkPhone() } + func providers() async throws -> [Components.Schemas.SocialIdentity] { try await global.providers() } + func unlinkProvider(_ provider: String) async throws -> Void { try await global.unlinkProvider(provider) } + func changePassword(current: String, new: String) async throws -> Void { try await global.changePassword(current: current, new: new) } + func validateSession() async throws -> Void { try await global.validateSession() } + func linkProvider(_ provider: String, outcome: DemoAccountLinkOutcome) async throws -> Components.Schemas.SocialIdentity { try await global.linkProvider(provider, outcome: outcome) } +} + +@MainActor extension DemoSession { + func accountState(makerspaceID: Int?) -> AccountState { + let global: DemoAccountRepository + if let repository = globalAccountRepository { global = repository } + else { global = DemoAccountRepository(scenario: accountScenario); globalAccountRepository = global } + let member: DemoAccountRepository + if let makerspaceID, let repository = memberAccountRepositories[makerspaceID] { member = repository } + else { + member = DemoAccountRepository(scenario: accountScenario) + if let makerspaceID { memberAccountRepositories[makerspaceID] = member } + } + let repository = DemoScopedAccountRepository(global: global, member: member) + return AccountState(repository: repository, makerspaceID: makerspaceID, + makerspaceName: makerspaceID.flatMap { [7: spaces[0], 8: spaces[1]][$0] } ?? String(localized: "Your account"), + currentDeviceGrantID: "83000000-0000-4000-8000-000000000001", + phoneCooldown: accountCooldown, + beginSecurityEnrollment: { [weak self] _ in + guard let self else { return } + self.securityEnrollment = true; self.scenario = .enrollment; self.error = nil; self.code = ""; self.password = ""; self.stage = .mfa + }, endSession: { [weak self] codes, message in self?.securityEnded(codes: codes, message: message) }, + membershipEnded: { [weak self] in + guard let self, let makerspaceID else { return } + self.availableSpaces.remove(makerspaceID - 7) + if let next = self.availableSpaces.sorted().first { self.selectedSpace = next; self.stage = .makerspace } + else { self.stage = .noMakerspaces } + }) + } + func routeAccountBlocker(_ blocker: DemoDeletionState.Blocker) { + guard stage == .shell else { return } + if let index = spaces.firstIndex(of: blocker.makerspace), availableSpaces.contains(index) { selectedSpace = index } + pendingAccountFix = blocker + } + func securityEnded(codes: [String], message: String?) { + password = ""; code = ""; error = nil; securityEnrollment = false + securityCodes = codes; securityMessage = message; stage = .securityCompletion + } + func finishSecurityTransition() { + securityCodes = []; securityMessage = nil; securityEnrollment = false; password = ""; code = ""; username = ""; error = nil; scenario = .password; stage = .signIn + } +} +#endif diff --git a/SpaceWorks/DemoDeletion.swift b/SpaceWorks/DemoDeletion.swift new file mode 100644 index 0000000..aa56029 --- /dev/null +++ b/SpaceWorks/DemoDeletion.swift @@ -0,0 +1,150 @@ +#if DEBUG +import Foundation +import Observation +import SwiftUI + +/// A local preview of the planned account-deletion journey. This has no transport or SDK dependency. +@MainActor @Observable +final class DemoDeletionState { + enum Stage: Equatable { case explanation, blockers, reauthenticate, confirm, deleted } + enum Scenario: String, CaseIterable { case blocked = "Outstanding obligations", ready = "Ready to delete", conflict = "Obligation added at confirmation" } + enum Method: String, CaseIterable { case password = "Password", authenticator = "Authenticator", phone = "Phone code" } + enum Destination: String { case payments = "Payments", activity = "Activity", bookings = "Bookings", events = "Events", staff = "Staff tools", support = "Makerspace support" } + struct Blocker: Identifiable, Equatable { + let id: String + let makerspace: String + let count: Int + let detail: String + let destination: Destination + } + private(set) var stage = Stage.explanation + private(set) var blockers: [Blocker] = [] + private(set) var error: String? + private(set) var active = true + var scenario = Scenario.blocked { didSet { if scenario != oldValue { observedConflict = false } } } + private var observedConflict = false + var method = Method.password + var credential = "" + var confirmation = "" + private var proofExpires: Date? + private let now: () -> Date + private let onDelete: () -> Void + init(now: @escaping () -> Date = Date.init, onDelete: @escaping () -> Void = {}) { self.now = now; self.onDelete = onDelete } + var canConfirm: Bool { active && stage == .confirm && confirmation == "DELETE" && (proofExpires ?? .distantPast) > now() } + func checkBlockers() { + guard active, stage != .deleted else { return } + credential = ""; confirmation = ""; proofExpires = nil; error = nil + blockers = scenario == .blocked ? Self.sampleBlockers : scenario == .conflict && observedConflict ? [Self.sampleBlockers[3]] : [] + stage = blockers.isEmpty ? .reauthenticate : .blockers + } + func authenticate() { + guard active, stage == .reauthenticate, blockers.isEmpty else { return } + let valid = method == .password ? credential == "DemoPass!123" : credential == "123456" + credential = "" + guard valid else { error = method == .password ? String(localized: "That password didn't work. Try again.") : String(localized: "Invalid or expired verification code."); return } + proofExpires = now().addingTimeInterval(300); confirmation = ""; error = nil; stage = .confirm + } + func deletePreviewAccount() { + guard active, stage == .confirm else { return } + guard (proofExpires ?? .distantPast) > now() else { + proofExpires = nil; confirmation = ""; error = String(localized: "Verify your identity again before deleting."); stage = .reauthenticate; return + } + guard confirmation == "DELETE" else { error = String(localized: "Type DELETE to confirm."); return } + if scenario == .conflict { + observedConflict = true + blockers = [Self.sampleBlockers[3]]; proofExpires = nil; confirmation = ""; stage = .blockers + error = String(localized: "An obligation changed. Review the updated blockers before continuing."); return + } + credential = ""; confirmation = ""; proofExpires = nil; blockers = []; error = nil; stage = .deleted + // Only the demo owner may clear its in-memory session. No real account operation exists here. + onDelete() + } + func invalidate() { active = false; credential = ""; confirmation = ""; proofExpires = nil; blockers = []; error = nil } + static let sampleBlockers: [Blocker] = [ + .init(id: "unpaid_charges", makerspace: "Fab Lab Kochi", count: 2, detail: String(localized: "Settle outstanding charges before deleting your account."), destination: .payments), + .init(id: "refund_in_progress", makerspace: "Fab Lab Kochi", count: 1, detail: String(localized: "A refund is still being processed."), destination: .payments), + .init(id: "deposit_held", makerspace: "Maker Space Bengaluru", count: 1, detail: String(localized: "A deposit is still held by the makerspace."), destination: .payments), + .init(id: "active_loans", makerspace: "Fab Lab Kochi", count: 1, detail: String(localized: "Return all borrowed equipment first."), destination: .activity), + .init(id: "open_requests", makerspace: "Maker Space Bengaluru", count: 1, detail: String(localized: "Resolve open equipment requests."), destination: .activity), + .init(id: "accountability_open", makerspace: "Fab Lab Kochi", count: 1, detail: String(localized: "An accountability review needs attention."), destination: .support), + .init(id: "open_service_jobs", makerspace: "Maker Space Bengaluru", count: 1, detail: String(localized: "A service job is still open."), destination: .support), + .init(id: "upcoming_bookings", makerspace: "Fab Lab Kochi", count: 1, detail: String(localized: "Cancel or complete upcoming bookings."), destination: .bookings), + .init(id: "upcoming_registrations", makerspace: "Maker Space Bengaluru", count: 1, detail: String(localized: "Cancel or complete upcoming event registrations."), destination: .events), + .init(id: "last_manager", makerspace: "Fab Lab Kochi", count: 1, detail: String(localized: "Assign another manager before leaving this makerspace."), destination: .staff), + .init(id: "organization_owner", makerspace: "Maker Space Bengaluru", count: 1, detail: String(localized: "Transfer organization ownership first."), destination: .support), + .init(id: "superadmin", makerspace: "This server", count: 1, detail: String(localized: "Use the web console to resolve server administration responsibilities."), destination: .support), + .init(id: "future_server_blocker", makerspace: "Fab Lab Kochi", count: 1, detail: String(localized: "A makerspace review must finish before deletion can proceed."), destination: .support) + ] +} + +struct DemoDeletionView: View { + @State private var state: DemoDeletionState + var onFix: ((DemoDeletionState.Blocker) -> Void)? + @State private var passwordFocused = false + @FocusState private var fieldFocused: Bool + init(onFix: ((DemoDeletionState.Blocker) -> Void)? = nil, onDelete: @escaping () -> Void) { + _state = State(initialValue: DemoDeletionState(onDelete: onDelete)); self.onFix = onFix + } + var body: some View { + ScrollView { VStack(alignment: .leading, spacing: 24) { + Text("Delete account").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Label("Demo only · no account will be deleted", systemImage: "info.circle").foregroundStyle(Color.swMuted) + Text("This deletes your whole Space Works account on this server, across every makerspace.").font(.headline) + Text("Payment and loan history is kept anonymised for legal and accounting records. Leaving one makerspace is a separate action.").foregroundStyle(Color.swMuted) + if let error = state.error { SWError(message: error) } + if state.stage == .explanation || state.stage == .blockers { + Picker("Deletion preview", selection: $state.scenario) { ForEach(DemoDeletionState.Scenario.allCases, id: \.self) { Text(LocalizedStringKey($0.rawValue)) } } + .accessibilityIdentifier("deletion-scenario") + Button(state.stage == .explanation ? "Check account obligations" : "Check again") { state.checkBlockers() } + .buttonStyle(SWPrimary()).accessibilityIdentifier("deletion-check") + } + switch state.stage { + case .explanation: Text("Review every makerspace's obligations, verify your identity, then confirm. You can leave at any time before confirmation.").foregroundStyle(Color.swMuted) + case .blockers: + Text("Resolve these before deleting").font(.title2.bold()) + ForEach(state.blockers) { blocker in + SWCard { + Text(blocker.makerspace).font(.headline) + Text(blocker.detail) + Text("\(blocker.count) outstanding").font(.subheadline).foregroundStyle(Color.swMuted) + if let onFix { Button("Open \(blocker.destination.rawValue)") { onFix(blocker) }.frame(minHeight: 48) } + else { NavigationLink("Open \(blocker.destination.rawValue)") { DemoDeletionResolutionView(blocker: blocker) }.frame(minHeight: 48) } + } + } + case .reauthenticate: + Text("Verify your identity").font(.title2.bold()) + Picker("Verification method", selection: $state.method) { ForEach(DemoDeletionState.Method.allCases, id: \.self) { Text(LocalizedStringKey($0.rawValue)) } } + .onChange(of: state.method) { _, _ in state.credential = "" } + if state.method == .password { + PasswordControl(title: String(localized: "Current password"), text: $state.credential, identifier: "deletion-password", focus: $passwordFocused) + Text("Use DemoPass!123 for this demo.").foregroundStyle(Color.swMuted) + } else { + TextField("Verification code", text: $state.credential).textContentType(.oneTimeCode).keyboardType(.numberPad).focused($fieldFocused) + .padding(16).background(Color.swPanel, in: RoundedRectangle(cornerRadius: 10)).overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)).accessibilityIdentifier("deletion-code") + Text("Use 123456 for this demo. No message or authenticator request was sent.").foregroundStyle(Color.swMuted) + } + Button("Verify identity") { state.authenticate() }.buttonStyle(SWPrimary()).accessibilityIdentifier("deletion-verify") + case .confirm: + Text("Confirm deletion").font(.title2.bold()) + Text("Type DELETE to clear this demo account. This preview only resets in-memory sample data.").foregroundStyle(Color.swMuted) + TextField("Type DELETE", text: $state.confirmation).textInputAutocapitalization(.characters).autocorrectionDisabled().focused($fieldFocused) + .padding(16).background(Color.swPanel, in: RoundedRectangle(cornerRadius: 10)).overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)).accessibilityIdentifier("deletion-confirmation") + Button("Delete demo account", role: .destructive) { state.deletePreviewAccount() }.buttonStyle(SWPrimary()).disabled(!state.canConfirm).accessibilityIdentifier("deletion-submit") + case .deleted: Label("Demo account cleared", systemImage: "checkmark.circle").font(.title2.bold()) + } + }.padding(24).frame(maxWidth: 680).frame(maxWidth: .infinity) }.background(Color.swBg).foregroundStyle(Color.swInk).tint(Color.swAccentInk) + .navigationTitle("Delete account").navigationBarTitleDisplayMode(.inline).onDisappear { state.credential = ""; state.confirmation = "" } + .toolbar { ToolbarItemGroup(placement: .keyboard) { Spacer(); Button("Done") { fieldFocused = false; passwordFocused = false } } } + } +} + +private struct DemoDeletionResolutionView: View { + let blocker: DemoDeletionState.Blocker + var body: some View { + ScrollView { VStack(alignment: .leading, spacing: 24) { + Text(LocalizedStringKey(blocker.destination.rawValue)).font(.largeTitle.bold()); Text(blocker.makerspace).font(.headline) + Text(blocker.detail); Text("This preview does not resolve real obligations. Open the matching makerspace task from your account to continue.").foregroundStyle(Color.swMuted) + }.padding(24).frame(maxWidth: 680).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle(LocalizedStringKey(blocker.destination.rawValue)) + } +} +#endif diff --git a/SpaceWorks/DemoEmailVerification.swift b/SpaceWorks/DemoEmailVerification.swift new file mode 100644 index 0000000..8ffa3f6 --- /dev/null +++ b/SpaceWorks/DemoEmailVerification.swift @@ -0,0 +1,34 @@ +#if DEBUG +import SwiftUI +import SpaceWorksAPI + +extension DemoSession { + func emailVerificationForm() -> AccountAccessState { + let flowID = emailFlowID + let form = AccountAccessState(step: .emailVerification, cooldown: emailCooldown, isDemo: true, + create: { _, _, _, _ in }, request: { _ in }, reset: { _, _, _ in }, + verify: { [weak self] code in + guard let self, self.stage == .emailVerification, self.emailFlowID == flowID else { throw CancellationError() } + guard code == "123456" else { throw APIError(status: 400, code: "invalid_code", detail: String(localized: "Invalid or expired verification code.")) } + self.demoEmailConfirmed = true + }, resendVerification: { [weak self] in + guard let self, self.stage == .emailVerification, self.emailFlowID == flowID else { throw CancellationError() } + }) + form.email = "meera@example.invalid" + return form + } + func finishEmailVerification(expectedFlowID: UUID) { + guard stage == .emailVerification, emailFlowID == expectedFlowID else { return } + stage = demoEmailConfirmed ? membershipStage : .signIn + } +} +struct DemoEmailVerificationView: View { + let session: DemoSession + private let flowID: UUID + @State private var form: AccountAccessState + init(session: DemoSession) { + self.session = session; flowID = session.emailFlowID; _form = State(initialValue: session.emailVerificationForm()) + } + var body: some View { AccountAccessView(form: form) { session.finishEmailVerification(expectedFlowID: flowID) } } +} +#endif diff --git a/SpaceWorks/DemoMemberActionsRepository.swift b/SpaceWorks/DemoMemberActionsRepository.swift index 305eb44..c3913e0 100644 --- a/SpaceWorks/DemoMemberActionsRepository.swift +++ b/SpaceWorks/DemoMemberActionsRepository.swift @@ -18,7 +18,7 @@ actor DemoMemberStore { func renewed(_ request: Int, due: Date) throws -> Int { var value = try object(activityJSON) var rows = value["active_hardware_loans"] as? [[String: Any]] ?? [] - guard let index = rows.firstIndex(where: { $0["request_id"] as? Int == request }) else { throw APIError(status: 404, code: "demo_not_found", detail: "This loan wasn't found.") } + guard let index = rows.firstIndex(where: { $0["request_id"] as? Int == request }) else { throw APIError(status: 404, code: "demo_not_found", detail: String(localized: "This loan wasn't found.")) } rows[index]["due_at"] = ISO8601DateFormatter().string(from: due); rows[index]["renewal_count"] = (rows[index]["renewal_count"] as? Int ?? 0) + 1 value["active_hardware_loans"] = rows; activityJSON = try json(value) return rows[index]["renewal_count"] as? Int ?? 1 @@ -41,14 +41,14 @@ actor DemoMemberStore { } func cancelBooking(_ token: String) throws -> Components.Schemas.MemberBooking { var value = try object(bookingsJSON); var rows = value["results"] as? [[String: Any]] ?? [] - guard let index = rows.firstIndex(where: { $0["public_token"] as? String == token }), rows[index]["can_cancel"] as? Bool == true else { throw APIError(status: 409, code: "demo_booking_conflict", detail: "This booking can no longer be cancelled.") } + guard let index = rows.firstIndex(where: { $0["public_token"] as? String == token }), rows[index]["can_cancel"] as? Bool == true else { throw APIError(status: 409, code: "demo_booking_conflict", detail: String(localized: "This booking can no longer be cancelled.")) } rows[index]["status"] = "cancelled"; rows[index]["can_cancel"] = false; value["results"] = rows; bookingsJSON = try json(value) let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 return try decoder.decode(Components.Schemas.MemberBooking.self, from: JSONSerialization.data(withJSONObject: rows[index])) } func cancelled(_ id: Int) throws { var value = try object(activityJSON); var rows = value["event_registrations"] as? [[String: Any]] ?? [] - guard let index = rows.firstIndex(where: { $0["registration_id"] as? Int == id }) else { throw APIError(status: 404, code: "demo_not_found", detail: "This registration wasn't found.") } + guard let index = rows.firstIndex(where: { $0["registration_id"] as? Int == id }) else { throw APIError(status: 404, code: "demo_not_found", detail: String(localized: "This registration wasn't found.")) } rows[index]["status"] = "cancelled"; rows[index]["checkin_token"] = NSNull(); rows[index]["can_cancel"] = false value["event_registrations"] = rows; activityJSON = try json(value) } @@ -60,6 +60,7 @@ actor DemoMemberActionsRepository: MemberActionsRepository { enum Scenario: String, CaseIterable { case success = "Success", refused = "Refused", conflict = "Conflict", throttled = "Rate limited", pending = "Awaiting approval", waitlisted = "Waitlisted" } let store: DemoMemberStore private let bootstrapJSON: String + private let makerspaceID: Int? private(set) var scenario: Scenario private var receipts: [String: (Data, Components.Schemas.RequestSubmitResponse)] = [:] private var savedProfile: Components.Schemas.AccountProfile @@ -68,29 +69,35 @@ actor DemoMemberActionsRepository: MemberActionsRepository { .init(feature: .events, channel: .in_app, enabled: true, available: true, mandatory_events: []), .init(feature: .events, channel: .native_push, enabled: false, available: false, mandatory_events: []) ]) - init(store: DemoMemberStore = DemoMemberStore(), scenario: Scenario = .success, name: String = "Meera Nair", bootstrapJSON: String = DebugMemberFixtures.bootstrap) { - self.store = store; self.scenario = scenario; self.bootstrapJSON = bootstrapJSON + init(store: DemoMemberStore = DemoMemberStore(), scenario: Scenario = .success, name: String = "Meera Nair", bootstrapJSON: String = DebugMemberFixtures.bootstrap, makerspaceID: Int? = nil) { + self.store = store; self.scenario = scenario; self.bootstrapJSON = bootstrapJSON; self.makerspaceID = makerspaceID let parts = name.split(separator: " ", maxSplits: 1).map(String.init) savedProfile = .init(display_name: name, first_name: parts.first ?? name, last_name: parts.count > 1 ? parts[1] : "", phone: "") } func preview(_ scenario: Scenario) { self.scenario = scenario } private func check() throws { switch scenario { - case .refused: throw APIError(status: 403, code: "demo_refused", detail: "This action isn't available for your membership.") - case .conflict: throw APIError(status: 409, code: "demo_conflict", detail: "This changed. Refresh and try again.") - case .throttled: throw APIError(status: 429, code: "demo_throttled", detail: "Too many requests. Try again later.", retryAfter: Date().addingTimeInterval(5)) + case .refused: throw APIError(status: 403, code: "demo_refused", detail: String(localized: "This action isn't available for your membership.")) + case .conflict: throw APIError(status: 409, code: "demo_conflict", detail: String(localized: "This changed. Refresh and try again.")) + case .throttled: throw APIError(status: 429, code: "demo_throttled", detail: String(localized: "Too many requests. Try again later."), retryAfter: Date().addingTimeInterval(5)) default: break } } func bootstrap() async throws -> Components.Schemas.TenantBootstrap { - return try JSONDecoder().decode(Components.Schemas.TenantBootstrap.self, from: Data(bootstrapJSON.utf8)) + guard let makerspaceID else { return try JSONDecoder().decode(Components.Schemas.TenantBootstrap.self, from: Data(bootstrapJSON.utf8)) } + var object = try JSONSerialization.jsonObject(with: Data(bootstrapJSON.utf8)) as! [String: Any] + var maker = object["makerspace"] as! [String: Any]; maker["id"] = makerspaceID + maker["name"] = makerspaceID == 7 ? "Fab Lab Kochi" : "Maker Space Bengaluru" + maker["slug"] = makerspaceID == 7 ? "fab-lab-kochi" : "maker-space-bengaluru" + object["makerspace"] = maker + return try JSONDecoder().decode(Components.Schemas.TenantBootstrap.self, from: JSONSerialization.data(withJSONObject: object)) } func submit(items: [Components.Schemas.RequestItemInput], purpose: String, key: String) async throws -> Components.Schemas.RequestSubmitResponse { try check() let encoder = JSONEncoder(); encoder.outputFormatting = .sortedKeys let payload = try encoder.encode(Components.Schemas.RequestSubmit(requested_for: purpose, items: items)) if let existing = receipts[key] { - guard existing.0 == payload else { throw APIError(status: 409, code: "idempotency_conflict", detail: "This request changed. Review it again.") } + guard existing.0 == payload else { throw APIError(status: 409, code: "idempotency_conflict", detail: String(localized: "This request changed. Review it again.")) } return existing.1 } let result = Components.Schemas.RequestSubmitResponse(public_token: UUID().uuidString, status: "pending") @@ -121,7 +128,7 @@ actor DemoMemberActionsRepository: MemberActionsRepository { let json = await store.activity() let activity = try decoder.decode(Components.Schemas.MemberActivity.self, from: Data(json.utf8)) guard activity.event_registrations?.contains(where: { $0.registration_id == registration && $0.status == "registered" }) == true else { - throw APIError(status: 403, code: "demo_ticket_unavailable", detail: "A ticket is available only after registration is confirmed.") + throw APIError(status: 403, code: "demo_ticket_unavailable", detail: String(localized: "A ticket is available only after registration is confirmed.")) } return Data(#""#.utf8) } @@ -133,10 +140,10 @@ actor DemoMemberActionsRepository: MemberActionsRepository { func preferences() async throws -> Components.Schemas.PreferenceResponse { try check(); return savedPreferences } func updatePreference(_ input: Components.Schemas.PatchedPreferenceWrite) async throws -> Components.Schemas.PreferenceResponse { try check() - guard let index = savedPreferences.cells.firstIndex(where: { $0.feature == input.feature && $0.channel == input.channel }) else { throw APIError(status: 404, code: "demo_not_found", detail: "This option wasn't found.") } + guard let index = savedPreferences.cells.firstIndex(where: { $0.feature == input.feature && $0.channel == input.channel }) else { throw APIError(status: 404, code: "demo_not_found", detail: String(localized: "This option wasn't found.")) } var cell = savedPreferences.cells[index] if let enabled = input.enabled { - guard !enabled || cell.available else { throw APIError(status: 400, code: "demo_unavailable", detail: "This option isn't available in this makerspace.") } + guard !enabled || cell.available else { throw APIError(status: 400, code: "demo_unavailable", detail: String(localized: "This option isn't available in this makerspace.")) } cell.enabled = enabled } if let daily = input.daily_digest { cell.daily_digest = daily } diff --git a/SpaceWorks/DemoMemberRepository.swift b/SpaceWorks/DemoMemberRepository.swift index ce752bb..25cd2f0 100644 --- a/SpaceWorks/DemoMemberRepository.swift +++ b/SpaceWorks/DemoMemberRepository.swift @@ -29,7 +29,7 @@ actor DemoMemberRepository: MemberRepository { } func product(_ id: Int) async throws -> Components.Schemas.PublicProduct { let inventory = try decode(Components.Schemas.PaginatedPublicProductList.self, DebugMemberFixtures.inventory) - guard let product = inventory.results.first(where: { $0.id == id }) else { throw APIError(status: 404, code: "not_found", detail: "This item is no longer available.") } + guard let product = inventory.results.first(where: { $0.id == id }) else { throw APIError(status: 404, code: "not_found", detail: String(localized: "This item is no longer available.")) } return product } func categories() async throws -> [Components.Schemas.PublicCategory] { try decode([Components.Schemas.PublicCategory].self, DebugMemberFixtures.categories) } @@ -48,7 +48,7 @@ actor DemoMemberRepository: MemberRepository { func events() async throws -> [Components.Schemas.PublicEvent] { try decode([Components.Schemas.PublicEvent].self, scenario == .empty ? "[]" : DebugMemberFixtures.events) } func spaces() async throws -> [Components.Schemas.PublicBookableSpace] { try decode([Components.Schemas.PublicBookableSpace].self, scenario == .empty ? "[]" : DebugMemberFixtures.spaces) } func card() async throws -> Components.Schemas.OwnMemberCard { - if scenario == .empty { throw APIError(status: 404, code: "not_issued", detail: "A membership card hasn't been issued yet.") } + if scenario == .empty { throw APIError(status: 404, code: "not_issued", detail: String(localized: "A membership card hasn't been issued yet.")) } var object = try JSONSerialization.jsonObject(with: Data(DebugMemberFixtures.card.utf8)) as! [String: Any] object["printed_name"] = name if scenario == .revokedCard { object["is_active"] = false; object["qr_active"] = false } diff --git a/SpaceWorks/DemoMode.swift b/SpaceWorks/DemoMode.swift index 4a734d3..9ee30ed 100644 --- a/SpaceWorks/DemoMode.swift +++ b/SpaceWorks/DemoMode.swift @@ -8,9 +8,9 @@ final class DemoSession { enum Role: String, CaseIterable { case member = "Member", staff = "Staff" } enum Scenario: String, CaseIterable { case password = "Password", mfa = "MFA", enrollment = "Enrollment", expired = "Expired challenge" - case offline = "Offline", throttled = "Too many requests", superadmin = "Superadmin", updateRequired = "Update required" + case noMemberships = "No makerspaces", offline = "Offline", throttled = "Too many requests", superadmin = "Superadmin", updateRequired = "Update required" } - enum Stage: Equatable { case welcome, signIn, mfa, recovery, makerspace, shell, webConsole, updateRequired } + enum Stage: Equatable { case welcome, signIn, mfa, recovery, makerspace, shell, webConsole, updateRequired, noMakerspaces, securityCompletion, emailVerification } var role = Role.member var scenario = Scenario.password var stage = Stage.welcome @@ -20,6 +20,19 @@ final class DemoSession { var error: String? var retryAfter: Date? var selectedSpace = 0 + var availableSpaces: Set = [0, 1] + var accountScenario = DemoAccountScenario.populated + var accountCooldown = AccountAccessCooldown() + var emailCooldown = AccountAccessCooldown() + var demoEmailConfirmed = false + var emailFlowID = UUID() + var securityEnrollment = false + var securityCodes: [String] = [] + var securityMessage: String? + var fixNotice: String? + var pendingAccountFix: DemoDeletionState.Blocker? + @ObservationIgnored var globalAccountRepository: DemoAccountRepository? + @ObservationIgnored var memberAccountRepositories: [Int: DemoAccountRepository] = [:] var notificationState = DemoNotificationState() @ObservationIgnored private var paymentRepositories: [Int: DemoPaymentRepository] = [:] var staffScenario = DemoStaffScenario.populated @@ -27,10 +40,13 @@ final class DemoSession { var profileDisplayName: String? var memberScenario = DemoMemberRepository.Scenario.populated let spaces = ["Fab Lab Kochi", "Maker Space Bengaluru"] + var membershipStage: Stage { availableSpaces.isEmpty ? .noMakerspaces : .makerspace } var name: String { profileDisplayName ?? (role == .member ? "Meera Nair" : "Arjun Rao") } init() { let arguments = ProcessInfo.processInfo.arguments if arguments.contains("--ui-testing") { + if arguments.contains("--demo-no-memberships") { scenario = .noMemberships } + if arguments.contains("--demo-account-no-mfa") { accountScenario = .noMFA } if arguments.contains("--demo-empty") { memberScenario = .empty } if arguments.contains("--demo-error") { memberScenario = .offline } if arguments.contains("--demo-revoked") { memberScenario = .revokedCard } @@ -42,29 +58,33 @@ final class DemoSession { } } func signIn() { - guard !username.isEmpty, !password.isEmpty else { error = "Enter your email or username and password."; return } + guard !username.isEmpty, !password.isEmpty else { error = String(localized: "Enter your email or username and password."); return } guard (retryAfter ?? .distantPast) <= Date() else { return } error = nil; password = "" + if scenario == .password, username.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "unverified", !demoEmailConfirmed { + emailFlowID = UUID(); stage = .emailVerification; return + } switch scenario { - case .offline: error = "Couldn't connect. Try again." - case .throttled: error = "Too many requests. Try again later."; retryAfter = Date().addingTimeInterval(10) - case .expired: error = "Your sign-in expired. Try again." + case .offline: error = String(localized: "Couldn't connect. Try again.") + case .throttled: error = String(localized: "Too many requests. Try again later."); retryAfter = Date().addingTimeInterval(10) + case .expired: error = String(localized: "Your sign-in expired. Try again.") case .superadmin: stage = .webConsole case .updateRequired: stage = .updateRequired - case .password: stage = .makerspace + case .noMemberships: availableSpaces = []; stage = .noMakerspaces + case .password: stage = membershipStage case .mfa, .enrollment: stage = .mfa } } func verify() { - guard code == "123456" || code == "sample-recovery" else { error = "That code didn’t work. Try again."; return } - code = ""; error = nil; stage = scenario == .enrollment ? .recovery : .makerspace + guard code == "123456" || code == "sample-recovery" else { error = String(localized: "That code didn’t work. Try again."); return } + code = ""; error = nil; stage = scenario == .enrollment ? .recovery : membershipStage } - func acceptDemoProvider() { error = nil; stage = scenario == .enrollment || scenario == .mfa ? .mfa : .makerspace } + func acceptDemoProvider() { error = nil; if scenario == .noMemberships { availableSpaces = []; stage = .noMakerspaces } else { stage = scenario == .enrollment || scenario == .mfa ? .mfa : membershipStage } } func paymentRepository(for makerspaceID: Int) -> DemoPaymentRepository { if let repository = paymentRepositories[makerspaceID] { return repository } let repository = DemoPaymentRepository(); paymentRepositories[makerspaceID] = repository; return repository } - func reset() { paymentRepositories = [:]; notificationState = DemoNotificationState(); username = ""; password = ""; code = ""; error = nil; retryAfter = nil; selectedSpace = 0; profileDisplayName = nil; actionScenario = .success; stage = .welcome } + func reset() { accountCooldown = AccountAccessCooldown(); emailCooldown = AccountAccessCooldown(); demoEmailConfirmed = false; emailFlowID = UUID(); fixNotice = nil; availableSpaces = [0, 1]; globalAccountRepository = nil; memberAccountRepositories = [:]; securityEnrollment = false; securityCodes = []; securityMessage = nil; pendingAccountFix = nil; paymentRepositories = [:]; notificationState = DemoNotificationState(); username = ""; password = ""; code = ""; error = nil; retryAfter = nil; selectedSpace = 0; profileDisplayName = nil; actionScenario = .success; stage = .welcome } } enum DemoProvider: String, CaseIterable, Identifiable { @@ -86,6 +106,10 @@ struct DemoRoot: View { if session.stage == .shell { DemoShell(session: session, linkRouter: linkRouter).id("\(session.role.rawValue).\(session.selectedSpace).\(session.memberScenario.rawValue)") } + else if session.stage == .emailVerification { DemoEmailVerificationView(session: session).id(session.emailFlowID) } + else if session.stage == .securityCompletion { + AccountSecurityCompletionView(codes: session.securityCodes, message: session.securityMessage ?? String(localized: "Sign in again to continue."), continueToSignIn: session.finishSecurityTransition) + } else if session.stage == .noMakerspaces { DemoAccountLandingView(session: session) } else { DemoAuthView(session: session) } }.background(Color.swBg) .safeAreaInset(edge: .bottom) { MobileLinkNotice(router: linkRouter) } @@ -97,13 +121,13 @@ struct DemoRoot: View { guard session.stage == .shell else { linkRouter.unbind(); return } linkRouter.bind(account: "demo." + session.role.rawValue, refresh: { guard session.stage == .shell else { throw CancellationError() } - return [7, 8] + return Set(session.availableSpaces.map { $0 + 7 }) }, select: { id in - guard session.stage == .shell, [7, 8].contains(id) else { throw CancellationError() } + guard session.stage == .shell, session.availableSpaces.contains(id - 7) else { throw CancellationError() } session.selectedSpace = id == 7 ? 0 : 1 }, resolveLegacy: { legacy in guard session.stage == .shell, !legacy.staffInbox || session.role == .staff, - let id = ["fab-lab-kochi": 7, "maker-space-bengaluru": 8][legacy.slug] else { throw CancellationError() } + let id = ["fab-lab-kochi": 7, "maker-space-bengaluru": 8][legacy.slug], session.availableSpaces.contains(id - 7) else { throw CancellationError() } return MobileLink(makerspaceID: id, destination: legacy.staffInbox ? .inbox : .home) }) Task { await linkRouter.process() } @@ -124,11 +148,12 @@ struct DemoAuthView: View { case .welcome: Text("Make yourself at home.").font(.largeTitle.bold()) Text("Explore sample makerspaces and complete app flows. Nothing is sent to a server.").foregroundStyle(Color.swMuted) - Picker("Demo role", selection: $session.role) { ForEach(DemoSession.Role.allCases, id: \.self) { Text($0.rawValue) } } + Picker("Demo role", selection: $session.role) { ForEach(DemoSession.Role.allCases, id: \.self) { Text(LocalizedStringKey($0.rawValue)) } } .pickerStyle(.segmented).accessibilityIdentifier("demo-role") - Picker("Sign-in preview", selection: $session.scenario) { ForEach(DemoSession.Scenario.allCases, id: \.self) { Text($0.rawValue) } } - Picker("Staff data preview", selection: $session.staffScenario) { ForEach(DemoStaffScenario.allCases, id: \.self) { Text($0.rawValue) } } - Picker("App data preview", selection: $session.memberScenario) { ForEach(DemoMemberRepository.Scenario.allCases, id: \.self) { Text($0.rawValue) } } + Picker("Sign-in preview", selection: $session.scenario) { ForEach(DemoSession.Scenario.allCases, id: \.self) { Text(LocalizedStringKey($0.rawValue)) } } + Picker("Account data preview", selection: $session.accountScenario) { ForEach(DemoAccountScenario.allCases, id: \.self) { Text(LocalizedStringKey($0.rawValue)) } } + Picker("Staff data preview", selection: $session.staffScenario) { ForEach(DemoStaffScenario.allCases, id: \.self) { Text(LocalizedStringKey($0.rawValue)) } } + Picker("App data preview", selection: $session.memberScenario) { ForEach(DemoMemberRepository.Scenario.allCases, id: \.self) { Text(LocalizedStringKey($0.rawValue)) } } Button("Continue as \(session.role.rawValue.lowercased())") { session.stage = .signIn }.buttonStyle(SWPrimary()) case .signIn: Text("Welcome back.").font(.largeTitle.bold()) @@ -147,8 +172,9 @@ struct DemoAuthView: View { TextField("Email or username", text: $session.username, prompt: Text("Email or username").foregroundColor(Color.swMuted)).textInputAutocapitalization(.never) .autocorrectionDisabled().submitLabel(.next).onSubmit { passwordFocused = true } .demoField().accessibilityIdentifier("demo-username") - PasswordControl(title: "Password", text: $session.password, identifier: "demo-password", focus: $passwordFocused) + PasswordControl(title: String(localized: "Password"), text: $session.password, identifier: "demo-password", focus: $passwordFocused) Text("Use any email and password in this demo.").font(.caption).foregroundStyle(Color.swMuted) + Text("Use unverified to preview email confirmation.").font(.caption).foregroundStyle(Color.swMuted) if let error = session.error { SWError(message: error) } TimelineView(.periodic(from: .now, by: 1)) { context in Button("Sign in") { session.signIn() }.buttonStyle(SWPrimary()).accessibilityIdentifier("demo-sign-in") @@ -162,7 +188,7 @@ struct DemoAuthView: View { if session.scenario == .enrollment { QRCodeView(value: "otpauth://totp/SpaceWorks:demo?secret=JBSWY3DPEHPK3PXP&issuer=SpaceWorks") Text("Can't scan? Enter this key").font(.headline) - Text("JBSWY3DPEHPK3PXP").font(.body.monospaced()).textSelection(.enabled) + Text(verbatim: "JBSWY3DPEHPK3PXP").font(.body.monospaced()).textSelection(.enabled) } Text("Authenticator or recovery code").font(.headline) TextField("Authenticator or recovery code", text: $session.code).demoField() @@ -174,11 +200,11 @@ struct DemoAuthView: View { case .recovery: Text("Save your recovery codes").font(.largeTitle.bold()) Text("Sample codes for this demo. Real codes appear once after enrollment.").foregroundStyle(Color.swMuted) - Text("sample-recovery\nsample-backup").font(.body.monospaced()).textSelection(.enabled) - Button("I saved my codes. Continue") { session.stage = .makerspace }.buttonStyle(SWPrimary()) + Text(verbatim: "sample-recovery\nsample-backup").font(.body.monospaced()).textSelection(.enabled) + Button("I saved my codes. Continue") { if session.securityEnrollment { session.securityEnded(codes: [], message: "Two-step sign-in was set up. Sign in again to continue.") } else { session.stage = session.membershipStage } }.buttonStyle(SWPrimary()) case .makerspace: Text("Choose a makerspace").font(.largeTitle.bold()) - ForEach(session.spaces.indices, id: \.self) { index in + ForEach(session.spaces.indices.filter { session.availableSpaces.contains($0) }, id: \.self) { index in Button { session.selectedSpace = index; session.stage = .shell } label: { Label(session.spaces[index], systemImage: "building.2").frame(maxWidth: .infinity, minHeight: 52, alignment: .leading) }.buttonStyle(.plain).accessibilityIdentifier("demo-space-\(index)") @@ -191,7 +217,7 @@ struct DemoAuthView: View { Text("Update required").font(.largeTitle.bold()) Text("Update the app to keep using this server. This is a preview of the server's minimum-version gate.").foregroundStyle(Color.swMuted) Button("Back to demo") { session.reset() }.buttonStyle(SWPrimary()) - case .shell: EmptyView() + case .shell, .noMakerspaces, .securityCompletion, .emailVerification: EmptyView() } } }.padding(24).frame(maxWidth: 600).frame(maxWidth: .infinity) @@ -238,7 +264,7 @@ struct DemoProviderView: View { } else { Label(session.name, systemImage: "person.crop.circle").font(.title2) } - Picker("Preview result", selection: $state.outcome) { ForEach(DemoProviderState.Outcome.allCases, id: \.self) { Text($0.rawValue) } } + Picker("Preview result", selection: $state.outcome) { ForEach(DemoProviderState.Outcome.allCases, id: \.self) { Text(LocalizedStringKey($0.rawValue)) } } if let error = state.error { SWError(message: error) } if provider != .phone || state.step == .code { Button(provider == .phone ? "Verify" : "Continue demo account") { @@ -262,19 +288,21 @@ struct DemoShell: View { @State private var capabilities: MemberCapabilities @State private var payments: PaymentsState @State private var staff: StaffState? + @State private var account: AccountState @State private var actions: DemoMemberActionsRepository @State private var columns = NavigationSplitViewVisibility.automatic init(session: DemoSession, linkRouter: MobileLinkRouter) { self.session = session; self.linkRouter = linkRouter + _account = State(initialValue: session.accountState(makerspaceID: session.selectedSpace + 7)) _staff = State(initialValue: session.role == .staff ? StaffState(repository: DemoStaffRepository(scenario: session.staffScenario, makerspaceID: session.selectedSpace + 7), makerspaceID: session.selectedSpace + 7, actions: ["accept_request", "reject_request", "assign_box", "issue_request", "return_request", "upload_evidence", "issue_direct_loan", "manage_events", "manage_bookings"], features: ["loans.kit_checklist"]) : nil) _payments = State(initialValue: PaymentsState(repository: session.paymentRepository(for: session.selectedSpace + 7), isDemo: true, sleep: { _ in })) let store = DemoMemberStore() - let actions = DemoMemberActionsRepository(store: store, scenario: session.actionScenario, name: session.name) + let actions = DemoMemberActionsRepository(store: store, scenario: session.actionScenario, name: session.name, makerspaceID: session.selectedSpace + 7) _actions = State(initialValue: actions) _cart = State(initialValue: RequestCartState(repository: actions)) _drafts = State(initialValue: MemberActionDrafts(repository: actions)) - _capabilities = State(initialValue: MemberCapabilities(repository: actions, makerspaceID: 7)) + _capabilities = State(initialValue: MemberCapabilities(repository: actions, makerspaceID: session.selectedSpace + 7)) _member = State(initialValue: MemberState(repository: DemoMemberRepository(scenario: session.memberScenario, name: session.name, store: store))) } var body: some View { @@ -283,7 +311,7 @@ struct DemoShell: View { if sizeClass == .regular { NavigationSplitView(columnVisibility: $columns) { List(AppTab.allCases) { tab in - Button(tab.rawValue) { + Button(LocalizedStringKey(tab.rawValue)) { navigation.selected = tab columns = geometry.size.width >= 1000 ? .all : .detailOnly }.frame(minHeight: 48).accessibilityIdentifier("nav-\(tab.rawValue)") @@ -295,7 +323,7 @@ struct DemoShell: View { TabView(selection: $navigation.selected) { ForEach(AppTab.allCases) { tab in destination(tab) - .tabItem { Label(tab.rawValue, systemImage: tab.symbol) }.tag(tab) + .tabItem { Label(LocalizedStringKey(tab.rawValue), systemImage: tab.symbol) }.tag(tab) .badge(tab == .inbox ? member.unreadCount ?? 0 : 0) } } @@ -305,7 +333,7 @@ struct DemoShell: View { }.onChange(of: geometry.size.width, initial: true) { _, width in columns = width >= 1000 ? .all : .detailOnly } - }.task { async let config: Void = capabilities.load(); await member.loadUnreadCount(); await config }.onDisappear { member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); staff?.invalidate() } + }.task { async let config: Void = capabilities.load(); await member.loadUnreadCount(); await config }.onDisappear { member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); staff?.invalidate(); account.invalidate() } .task(id: linkRouter.approved?.id) { if let approved = linkRouter.approved, approved.link.makerspaceID == session.selectedSpace + 7 { await MobileLinkDestination.open(approved, router: linkRouter, member: member, navigation: navigation, drafts: drafts, payment: { id in @@ -313,14 +341,45 @@ struct DemoShell: View { }) } } + .onChange(of: capabilities.selfService, initial: true) { _, _ in account.updateCapabilities(selfService: capabilities.selfService, exports: capabilities.exports) } + .onChange(of: capabilities.exports, initial: true) { _, _ in account.updateCapabilities(selfService: capabilities.selfService, exports: capabilities.exports) } + .task(id: session.pendingAccountFix?.id) { await routePendingAccountFix() } + .safeAreaInset(edge: .bottom) { + if let message = session.fixNotice { + HStack { Text(verbatim: message).foregroundStyle(Color.swMuted); Button("Done") { session.fixNotice = nil }.frame(minHeight: 48) }.padding(16).background(Color.swSurface) + } + } .onChange(of: session.actionScenario) { _, scenario in Task { await actions.preview(scenario) } } .onChange(of: sizeClass) { _, value in if value == .compact { navigation.compactInventory() } } } + private func routePendingAccountFix() async { + guard let blocker = session.pendingAccountFix, session.stage == .shell else { return } + let maker = session.selectedSpace + switch blocker.destination { + case .payments: navigation.open(.payments, tab: .profile) + case .activity: await member.loadActivity(); navigation.path(.activity).wrappedValue = []; navigation.selected = .activity + case .bookings: + await member.loadBookings() + guard session.selectedSpace == maker, session.pendingAccountFix?.id == blocker.id else { return } + if let row = member.bookings.first { navigation.open(.booking(row.public_token), tab: .activity) } + else { navigation.selected = .activity } + case .events: + await member.loadActivity() + guard session.selectedSpace == maker, session.pendingAccountFix?.id == blocker.id else { return } + if let row = member.activity?.event_registrations?.first { navigation.open(.registration(row.registration_id), tab: .activity) } + else { navigation.selected = .activity } + case .staff: + if staff != nil { navigation.open(.staff, tab: .profile) } + else { navigation.open(.account, tab: .profile); session.fixNotice = String(localized: "Ask your makerspace to update staff responsibilities before deleting.") } + case .support: navigation.open(.account, tab: .profile); session.fixNotice = blocker.detail + String(localized: " Ask your makerspace for help.") + } + if session.selectedSpace == maker, session.pendingAccountFix?.id == blocker.id { session.pendingAccountFix = nil } + } private func destination(_ tab: AppTab) -> some View { NavigationStack(path: navigation.path(tab)) { - DemoDestination(session: session, member: member, tab: tab, actions: actions, cart: cart, navigation: navigation, capabilities: capabilities, drafts: drafts, payments: payments, staff: staff, linkRouter: linkRouter) + DemoDestination(session: session, member: member, tab: tab, actions: actions, cart: cart, navigation: navigation, capabilities: capabilities, drafts: drafts, payments: payments, staff: staff, account: account, linkRouter: linkRouter) .navigationDestination(for: MemberRoute.self) { route in - MemberRouteView(route: route, member: member, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, isDemo: true, payments: payments, staff: staff) { + MemberRouteView(route: route, member: member, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, isDemo: true, payments: payments, staff: staff, account: account, onDemoDelete: session.reset, onDemoFix: session.routeAccountBlocker) { if let profile = try? await actions.profile() { session.profileDisplayName = profile.display_name } } } @@ -338,6 +397,7 @@ struct DemoDestination: View { let drafts: MemberActionDrafts let payments: PaymentsState let staff: StaffState? + let account: AccountState let linkRouter: MobileLinkRouter var body: some View { Group { @@ -357,6 +417,7 @@ struct DemoDestination: View { if let error = capabilities.error { SWError(message: error); Button("Retry account options") { Task { await capabilities.load() } }.frame(minHeight: 48) } if capabilities.selfService { NavigationLink("Edit profile", value: MemberRoute.contact).frame(minHeight: 48) } if capabilities.preferences { NavigationLink("Communication preferences", value: MemberRoute.preferences).frame(minHeight: 48) } + NavigationLink("Account and security", value: MemberRoute.account).frame(minHeight: 48).accessibilityIdentifier("account-entry") if staff != nil { NavigationLink("Staff tools", value: MemberRoute.staff).frame(minHeight: 48).accessibilityIdentifier("staff-tools") } NavigationLink("Payments", value: MemberRoute.payments).frame(minHeight: 48).accessibilityIdentifier("payments-entry") NavigationLink("Notifications") { @@ -368,18 +429,18 @@ struct DemoDestination: View { } }.frame(minHeight: 48).accessibilityIdentifier("notifications-entry") Picker("Action preview result", selection: Binding(get: { session.actionScenario }, set: { session.actionScenario = $0 })) { - ForEach(DemoMemberActionsRepository.Scenario.allCases, id: \.self) { Text($0.rawValue) } + ForEach(DemoMemberActionsRepository.Scenario.allCases, id: \.self) { Text(LocalizedStringKey($0.rawValue)) } }.frame(minHeight: 48).accessibilityIdentifier("action-preview-result") Button("Show helpful tips") { member.replayGuidance() }.frame(minHeight: 48) Button("Sign out") { session.reset() }.frame(minHeight: 48) } }.padding(24).frame(maxWidth: 960).frame(maxWidth: .infinity) } } - }.background(Color.swBg).navigationTitle(tab.rawValue) + }.background(Color.swBg).navigationTitle(LocalizedStringKey(tab.rawValue)) .toolbar { ToolbarItem(placement: .topBarTrailing) { Menu { - ForEach(session.spaces.indices, id: \.self) { index in Button(session.spaces[index]) { linkRouter.cancelIntent(); session.selectedSpace = index } } + ForEach(session.spaces.indices.filter { session.availableSpaces.contains($0) }, id: \.self) { index in Button(session.spaces[index]) { linkRouter.cancelIntent(); session.selectedSpace = index } } } label: { Label(session.spaces[session.selectedSpace], systemImage: "building.2") } } } diff --git a/SpaceWorks/DemoNotificationsView.swift b/SpaceWorks/DemoNotificationsView.swift index 480a5ee..675587f 100644 --- a/SpaceWorks/DemoNotificationsView.swift +++ b/SpaceWorks/DemoNotificationsView.swift @@ -22,7 +22,7 @@ struct DemoNotificationsView: View { Toggle("Preview permission denied", isOn: $state.permissionDenied).frame(minHeight: 48) if state.permissionDenied { Text("Notifications are off for this device.").font(.headline) - Button("Open system settings") { state.feedback = "In a connected app, this opens notification settings for your device." }.frame(minHeight: 48) + Button("Open system settings") { state.feedback = String(localized: "In a connected app, this opens notification settings for your device.") }.frame(minHeight: 48) } else { Button(state.enabled ? "Turn off demo notifications" : "Enable notifications") { state.enabled.toggle(); state.feedback = state.enabled ? "Demo notifications are enabled." : "Demo notifications are off." } .buttonStyle(SWPrimary()).accessibilityIdentifier("enable-demo-notifications") @@ -37,7 +37,7 @@ struct DemoNotificationsView: View { Button("Preview older notification") { route(.init(makerspaceID: makerspaceID, destination: .inbox)) }.frame(minHeight: 48) Button("Preview unavailable item") { route(.init(makerspaceID: makerspaceID, destination: .product(99999))) }.frame(minHeight: 48) Button("Preview unauthorized makerspace") { route(.init(makerspaceID: 999, destination: .inbox)) }.frame(minHeight: 48).accessibilityIdentifier("demo-push-denied") - Button("Preview device refresh") { state.rotations += 1; state.feedback = "Demo notification settings refreshed. No notifications were sent." }.frame(minHeight: 48) + Button("Preview device refresh") { state.rotations += 1; state.feedback = String(localized: "Demo notification settings refreshed. No notifications were sent.") }.frame(minHeight: 48) }.disabled(!state.enabled || state.permissionDenied) }.padding(24).frame(maxWidth: 640).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle("Notifications") diff --git a/SpaceWorks/DemoProviderState.swift b/SpaceWorks/DemoProviderState.swift index 3d1fdbb..53b1f81 100644 --- a/SpaceWorks/DemoProviderState.swift +++ b/SpaceWorks/DemoProviderState.swift @@ -18,7 +18,7 @@ final class DemoProviderState { func sendCode() { guard now() >= resendAfter else { return } guard phone.count >= 6, phone.count <= 14, phone.allSatisfy({ $0.isASCII && $0.isNumber }) else { - error = "Enter a valid phone number."; return + error = String(localized: "Enter a valid phone number."); return } code = ""; error = nil; resendAfter = now().addingTimeInterval(60); step = .code } @@ -26,11 +26,11 @@ final class DemoProviderState { func complete(phone: Bool) -> Bool { error = nil switch outcome { - case .unavailable: error = "This sign-in option isn't available on this device."; return false - case .rejected: error = "Sign-in couldn't be completed. Try again."; return false - case .expired: error = "That verification code expired. Request another code."; return false + case .unavailable: error = String(localized: "This sign-in option isn't available on this device."); return false + case .rejected: error = String(localized: "Sign-in couldn't be completed. Try again."); return false + case .expired: error = String(localized: "That verification code expired. Request another code."); return false case .success: - if phone && code != "123456" { error = "That code didn't work. Check it and try again."; return false } + if phone && code != "123456" { error = String(localized: "That code didn't work. Check it and try again."); return false } code = ""; return true } } diff --git a/SpaceWorks/EventRegistrationState.swift b/SpaceWorks/EventRegistrationState.swift index c27fa7e..f6f62e0 100644 --- a/SpaceWorks/EventRegistrationState.swift +++ b/SpaceWorks/EventRegistrationState.swift @@ -23,14 +23,14 @@ final class EventRegistrationState { } func submit(now: Date = Date()) async { guard active, !busy, result == nil, (retryAfter ?? .distantPast) <= now else { return } - guard event.registration_open else { error = "Registration is closed."; return } - guard !event.ticket_selection_required || ticket != nil else { error = "Choose a ticket type."; return } - if let ticket, !event.ticket_types.contains(where: { $0.id == ticket }) { error = "Choose an available ticket type."; return } - guard phone.count <= 64, invite.count <= 128, promo.count <= 128 else { error = "Check your contact details and codes, then try again."; return } + guard event.registration_open else { error = String(localized: "Registration is closed."); return } + guard !event.ticket_selection_required || ticket != nil else { error = String(localized: "Choose a ticket type."); return } + if let ticket, !event.ticket_types.contains(where: { $0.id == ticket }) { error = String(localized: "Choose an available ticket type."); return } + guard phone.count <= 64, invite.count <= 128, promo.count <= 128 else { error = String(localized: "Check your contact details and codes, then try again."); return } let normalizedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) - guard normalizedEmail.isEmpty || (normalizedEmail.contains("@") && !normalizedEmail.contains(where: { $0.isWhitespace })) else { error = "Enter a valid email address."; return } - guard event.registration_audience.value1 != .invite || !invite.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { error = "Enter your invitation code."; return } - guard let answers = form.validatedAnswers() else { error = form.unavailable ? "This form isn't supported in the app yet." : "Check the highlighted questions."; return } + guard normalizedEmail.isEmpty || (normalizedEmail.contains("@") && !normalizedEmail.contains(where: { $0.isWhitespace })) else { error = String(localized: "Enter a valid email address."); return } + guard event.registration_audience.value1 != .invite || !invite.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { error = String(localized: "Enter your invitation code."); return } + guard let answers = form.validatedAnswers() else { error = form.unavailable ? String(localized: "This form isn't supported in the app yet.") : String(localized: "Check the highlighted questions."); return } var body: [String: Any] = ["custom_answers": answers] for (key, value) in [("phone", phone), ("email", email), ("invite_code", invite), ("promo_code", promo)] where !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { body[key] = value.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/SpaceWorks/EventViews.swift b/SpaceWorks/EventViews.swift index 5079967..0f6841a 100644 --- a/SpaceWorks/EventViews.swift +++ b/SpaceWorks/EventViews.swift @@ -37,15 +37,15 @@ struct EventDetailView: View { Text("Choose a ticket type").tag(Int?.none) ForEach(Array(event.ticket_types.enumerated()), id: \.offset) { _, ticket in if let id = ticket.id { - Text((ticket.name ?? "Ticket") + (ticket.amount.map { " · \(event.currency) \($0)" } ?? "")).tag(Int?.some(id)) + Text((ticket.name ?? String(localized: "Ticket")) + (ticket.amount.map { " · \(event.currency) \($0)" } ?? "")).tag(Int?.some(id)) } } }.frame(minHeight: 48) } - if event.registration_audience.value1 == .invite { input("Invitation code", value: $state.invite).textInputAutocapitalization(.never).autocorrectionDisabled() } - if event.promo_codes_enabled { input("Promo code (optional)", value: $state.promo).textInputAutocapitalization(.never).autocorrectionDisabled() } - input("Phone number (optional)", value: $state.phone).textContentType(.telephoneNumber).keyboardType(.phonePad) - input("Email (optional)", value: $state.email).textContentType(.emailAddress).keyboardType(.emailAddress).textInputAutocapitalization(.never).autocorrectionDisabled() + if event.registration_audience.value1 == .invite { input(String(localized: "Invitation code"), value: $state.invite).textInputAutocapitalization(.never).autocorrectionDisabled() } + if event.promo_codes_enabled { input(String(localized: "Promo code (optional)"), value: $state.promo).textInputAutocapitalization(.never).autocorrectionDisabled() } + input(String(localized: "Phone number (optional)"), value: $state.phone).textContentType(.telephoneNumber).keyboardType(.phonePad) + input(String(localized: "Email (optional)"), value: $state.email).textContentType(.emailAddress).keyboardType(.emailAddress).textInputAutocapitalization(.never).autocorrectionDisabled() MemberCustomFields(form: state.form) if let error = state.error { SWError(message: error) } TimelineView(.periodic(from: .now, by: 1)) { tick in @@ -67,7 +67,7 @@ struct EventDetailView: View { } } private func resultTitle(_ value: String) -> String { - switch value { case "registered": return "Registration confirmed"; case "waitlisted": return "You're on the waitlist"; case "pending_approval": return "Awaiting approval"; default: return "Registration received" } + switch value { case "registered": return String(localized: "Registration confirmed"); case "waitlisted": return String(localized: "You're on the waitlist"); case "pending_approval": return String(localized: "Awaiting approval"); default: return String(localized: "Registration received") } } } @@ -88,7 +88,7 @@ struct MemberRegistrationView: View { if isDemo { Text("Demo mode · sample event ticket").font(.caption.bold()).foregroundStyle(Color.swMuted) } Text(registration.event_title).font(.largeTitle.bold()) Text(registration.starts_at.formatted(date: .abbreviated, time: .shortened)).foregroundStyle(Color.swMuted) - Text(state.cancelled ? "Cancelled" : registration.status.replacingOccurrences(of: "_", with: " ").capitalized).font(.headline) + Text(state.cancelled ? "Cancelled" : NativeStatusCopy.label(registration.status)).font(.headline) if let position = registration.waitlist_position, !state.cancelled { Text("Waitlist position: \(position)").foregroundStyle(Color.swMuted) } if state.cancelled { Text("Your registration was cancelled. Cancelling doesn't automatically issue a refund.").foregroundStyle(Color.swMuted) } else if registration.status == "registered" { diff --git a/SpaceWorks/IntroductionView.swift b/SpaceWorks/IntroductionView.swift index 870ece0..11fae9e 100644 --- a/SpaceWorks/IntroductionView.swift +++ b/SpaceWorks/IntroductionView.swift @@ -5,10 +5,10 @@ struct IntroductionView: View { @State private var page = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.dynamicTypeSize) private var typeSize - private let headings = ["Make room.", "Borrow with confidence.", "One place for your making."] - private let details = ["Find tools, book a space, and see what's next at your makerspace.", - "Request what you need and keep an eye on your loans and due dates.", - "Keep bookings, event tickets, payments, and updates together."] + private let headings = [String(localized: "Make room."), String(localized: "Borrow with confidence."), String(localized: "One place for your making.")] + private let details = [String(localized: "Find tools, book a space, and see what's next at your makerspace."), + String(localized: "Request what you need and keep an eye on your loans and due dates."), + String(localized: "Keep bookings, event tickets, payments, and updates together.")] private let symbols = [["wrench.and.screwdriver", "shippingbox", "hammer"], ["list.clipboard", "calendar", "checkmark.seal"], ["calendar", "ticket", "tray"]] diff --git a/SpaceWorks/MemberActionViews.swift b/SpaceWorks/MemberActionViews.swift index a93c465..7bb4b8d 100644 --- a/SpaceWorks/MemberActionViews.swift +++ b/SpaceWorks/MemberActionViews.swift @@ -14,7 +14,7 @@ struct RequestCartView: View { if let receipt = state.receipt { SWCard { Label("Request received", systemImage: "checkmark.circle").font(.largeTitle.bold()) - Text(receipt.status.replacingOccurrences(of: "_", with: " ").capitalized).font(.title2) + Text(NativeStatusCopy.label(receipt.status)).font(.title2) Text("Your makerspace will review your request and confirm collection and due dates.").foregroundStyle(Color.swMuted) Text("Request reference").font(.headline) Text(receipt.public_token).font(.body.monospaced()).textSelection(.enabled) diff --git a/SpaceWorks/MemberActionsState.swift b/SpaceWorks/MemberActionsState.swift index 7406d38..00206a6 100644 --- a/SpaceWorks/MemberActionsState.swift +++ b/SpaceWorks/MemberActionsState.swift @@ -40,10 +40,10 @@ final class RequestCartState { func add(_ product: Components.Schemas.PublicProduct) { guard !busy, active else { return } if let index = lines.firstIndex(where: { $0.id == product.id }) { - guard lines[index].quantity < 99 else { error = "Choose up to 99 of each item."; return } + guard lines[index].quantity < 99 else { error = String(localized: "Choose up to 99 of each item."); return } lines[index].quantity += 1 } else { - guard lines.count < 20 else { error = "Request up to 20 different items at once."; return } + guard lines.count < 20 else { error = String(localized: "Request up to 20 different items at once."); return } lines.append(Line(product: product, quantity: 1)) } changed() @@ -55,8 +55,8 @@ final class RequestCartState { } func submit(now: Date = Date()) async { guard active, !busy, (retryAfter ?? .distantPast) <= now else { return } - guard !lines.isEmpty else { error = "Add an item before submitting your request."; return } - guard purpose.count <= 500 else { error = "Keep the purpose to 500 characters or fewer."; return } + guard !lines.isEmpty else { error = String(localized: "Add an item before submitting your request."); return } + guard purpose.count <= 500 else { error = String(localized: "Keep the purpose to 500 characters or fewer."); return } busy = true; error = nil; receipt = nil let expected = generation let items = lines.map { Components.Schemas.RequestItemInput(product_id: $0.id, quantity: $0.quantity) } diff --git a/SpaceWorks/MemberCapabilities.swift b/SpaceWorks/MemberCapabilities.swift index 2a06c48..e365fa2 100644 --- a/SpaceWorks/MemberCapabilities.swift +++ b/SpaceWorks/MemberCapabilities.swift @@ -12,6 +12,7 @@ import SpaceWorksAPI let makerspaceID: Int init(repository: any MemberActionsRepository, makerspaceID: Int) { self.repository = repository; self.makerspaceID = makerspaceID } var selfService: Bool { bootstrap?.modules.contains("membership") == true && bootstrap?.modules.contains("member_accounts") == true && bootstrap?.features.contains("membership.self_service") == true } + var exports: Bool { bootstrap?.modules.contains("membership") == true && bootstrap?.modules.contains("member_accounts") == true && bootstrap?.features.contains("membership.self_export") == true } var preferences: Bool { bootstrap?.modules.contains("notifications") == true && bootstrap?.features.contains("notifications.member_preferences") == true } var digests: Bool { preferences && bootstrap?.features.contains("notifications.digests") == true } func load(now: Date = Date()) async { @@ -19,7 +20,7 @@ import SpaceWorksAPI busy = true; error = nil; defer { busy = false } do { let value = try await repository.bootstrap(); guard active else { return } - guard value.makerspace.id == makerspaceID else { throw APIError(status: 403, code: "wrong_makerspace", detail: "This configuration belongs to another makerspace.") } + guard value.makerspace.id == makerspaceID else { throw APIError(status: 403, code: "wrong_makerspace", detail: String(localized: "This configuration belongs to another makerspace.")) } bootstrap = value; retryAfter = nil } catch { guard active else { return }; bootstrap = nil; self.error = APIError.display(error); retryAfter = APIError.retryDeadline(error) } } diff --git a/SpaceWorks/MemberCustomForm.swift b/SpaceWorks/MemberCustomForm.swift index c96f169..ccedfc4 100644 --- a/SpaceWorks/MemberCustomForm.swift +++ b/SpaceWorks/MemberCustomForm.swift @@ -43,28 +43,28 @@ final class MemberCustomForm { let value = (text[question.id] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let choices = multiple[question.id] ?? [] let empty = question.kind == "multi_choice" ? choices.isEmpty : value.isEmpty - if empty { if question.required { errors[question.id] = "This question is required." }; continue } + if empty { if question.required { errors[question.id] = String(localized: "This question is required.") }; continue } if question.kind == "multi_choice" { - guard choices.isSubset(of: Set(question.options)) else { errors[question.id] = "Choose one of the available options."; continue } + guard choices.isSubset(of: Set(question.options)) else { errors[question.id] = String(localized: "Choose one of the available options."); continue } values[question.id] = choices.sorted() } else if question.kind == "number" { let normalized = value.replacingOccurrences(of: Locale.current.decimalSeparator ?? ".", with: ".") guard normalized.range(of: #"^[+-]?[0-9]+(?:\.[0-9]+)?$"#, options: .regularExpression) != nil, - let decimal = Decimal(string: normalized, locale: Locale(identifier: "en_US_POSIX")) else { errors[question.id] = "Enter a valid number."; continue } + let decimal = Decimal(string: normalized, locale: Locale(identifier: "en_US_POSIX")) else { errors[question.id] = String(localized: "Enter a valid number."); continue } let number = NSDecimalNumber(decimal: decimal) - guard number.doubleValue.isFinite else { errors[question.id] = "Enter a valid number."; continue } + guard number.doubleValue.isFinite else { errors[question.id] = String(localized: "Enter a valid number."); continue } values[question.id] = number } else if question.kind == "date" { let formatter = DateFormatter(); formatter.locale = Locale(identifier: "en_US_POSIX"); formatter.calendar = Calendar(identifier: .gregorian) formatter.dateFormat = "yyyy-MM-dd"; formatter.isLenient = false guard value.range(of: #"^[0-9]{4}-[0-9]{2}-[0-9]{2}$"#, options: .regularExpression) != nil, - let date = formatter.date(from: value), formatter.string(from: date) == value else { errors[question.id] = "Enter a date as YYYY-MM-DD."; continue } + let date = formatter.date(from: value), formatter.string(from: date) == value else { errors[question.id] = String(localized: "Enter a date as YYYY-MM-DD."); continue } values[question.id] = value } else if question.kind == "yes_no" { - guard ["true", "false"].contains(value) else { errors[question.id] = "Choose Yes or No."; continue } + guard ["true", "false"].contains(value) else { errors[question.id] = String(localized: "Choose Yes or No."); continue } values[question.id] = value == "true" } else if ["single_choice", "dropdown"].contains(question.kind) { - guard question.options.contains(value) else { errors[question.id] = "Choose one of the available options."; continue } + guard question.options.contains(value) else { errors[question.id] = String(localized: "Choose one of the available options."); continue } values[question.id] = value } else { values[question.id] = value } } @@ -76,11 +76,11 @@ struct MemberCustomFields: View { @Bindable var form: MemberCustomForm var body: some View { if form.unavailable { - SWError(message: "This form uses a question type the app doesn't support yet. Use the web console or ask your makerspace for help.") + SWError(message: String(localized: "This form uses a question type the app doesn't support yet. Use the web console or ask your makerspace for help.")) } else { ForEach(form.questions) { question in VStack(alignment: .leading, spacing: 8) { - Text(question.label + (question.required ? " (required)" : "")).font(.headline) + Text(question.label + (question.required ? String(localized: " (required)") : "")).font(.headline) field(question) if let error = form.errors[question.id] { Text(error).foregroundStyle(Color.swDanger) } } diff --git a/SpaceWorks/MemberNavigation.swift b/SpaceWorks/MemberNavigation.swift index 8634a97..7df87ea 100644 --- a/SpaceWorks/MemberNavigation.swift +++ b/SpaceWorks/MemberNavigation.swift @@ -1,7 +1,7 @@ import SwiftUI enum MemberRoute: Hashable { - case product(Int), spaces, space(String), event(String), booking(String), registration(Int), contact, preferences, payments, payment(Int), staff + case product(Int), spaces, space(String), event(String), booking(String), registration(Int), contact, preferences, payments, payment(Int), staff, account } /// Memory-only navigation belongs to one authenticated account/makerspace, outside adaptive branches. diff --git a/SpaceWorks/MemberRoutes.swift b/SpaceWorks/MemberRoutes.swift index e460c71..d8a86f2 100644 --- a/SpaceWorks/MemberRoutes.swift +++ b/SpaceWorks/MemberRoutes.swift @@ -13,6 +13,11 @@ struct MemberRouteView: View { let isDemo: Bool var payments: PaymentsState? = nil var staff: StaffState? = nil + var account: AccountState? = nil + #if DEBUG + var onDemoDelete: (() -> Void)? = nil + var onDemoFix: ((DemoDeletionState.Blocker) -> Void)? = nil + #endif var profileSaved: () async -> Void = {} var body: some View { switch route { @@ -28,6 +33,14 @@ struct MemberRouteView: View { } else { missing("Event unavailable", load: member.eventsLoad) { await member.loadEvents() }.task { if member.eventsLoad == .idle { await member.loadEvents() } } } case .booking(let token): BookingDestination(member: member, repository: actions, token: token, isDemo: isDemo) case .registration(let id): RegistrationDestination(member: member, repository: actions, id: id, isDemo: isDemo, drafts: drafts) + case .account: + if let account { + #if DEBUG + AccountView(state: account, privacyURL: Brand.privacyURL.flatMap(URL.init(string:)), supportURL: Brand.supportURL.flatMap(URL.init(string:)), onDemoDelete: onDemoDelete, onDemoFix: onDemoFix) + #else + AccountView(state: account, privacyURL: Brand.privacyURL.flatMap(URL.init(string:)), supportURL: Brand.supportURL.flatMap(URL.init(string:))) + #endif + } else { disabledFeature("Sign in again to manage this account.") } case .staff: if let staff { StaffView(state: staff) } else { disabledFeature("Staff tools require an authorized native staff session.") } diff --git a/SpaceWorks/MemberSettingsState.swift b/SpaceWorks/MemberSettingsState.swift index ff0d322..7a4db72 100644 --- a/SpaceWorks/MemberSettingsState.swift +++ b/SpaceWorks/MemberSettingsState.swift @@ -33,13 +33,13 @@ final class ProfileEditorState { } func save(now: Date = Date()) async { guard active, !busy, loaded, dirty, (retryAfter ?? .distantPast) <= now else { return } - guard displayName.count <= 200 else { error = "Keep your display name to 200 characters or fewer."; return } - guard firstName.count <= 150, lastName.count <= 150 else { error = "Keep each name to 150 characters or fewer."; return } - guard phone.count <= 32 else { error = "Keep your phone number to 32 characters or fewer."; return } + guard displayName.count <= 200 else { error = String(localized: "Keep your display name to 200 characters or fewer."); return } + guard firstName.count <= 150, lastName.count <= 150 else { error = String(localized: "Keep each name to 150 characters or fewer."); return } + guard phone.count <= 32 else { error = String(localized: "Keep your phone number to 32 characters or fewer."); return } busy = true; error = nil; notice = nil; defer { busy = false } do { let value = try await repository.updateProfile(.init(display_name: displayName, first_name: firstName, last_name: lastName, phone: phone)) - guard active else { return }; original = value; discard(); notice = "Profile saved." + guard active else { return }; original = value; discard(); notice = String(localized: "Profile saved.") } catch { if active { self.error = APIError.display(error); retryAfter = APIError.retryDeadline(error) } } } func invalidate() { active = false; original = nil; displayName = ""; firstName = ""; lastName = ""; phone = ""; error = nil; notice = nil } @@ -64,7 +64,7 @@ final class CommunicationPreferencesState { func set(_ cell: Components.Schemas.PreferenceCell, enabled: Bool, now: Date = Date()) async { guard active, !busy, (retryAfter ?? .distantPast) <= now else { return } guard cells.contains(where: { $0.feature == cell.feature && $0.channel == cell.channel && $0.enabled == cell.enabled }) else { return } - guard !enabled || cell.available else { error = "This notification option isn't available in your makerspace."; return } + guard !enabled || cell.available else { error = String(localized: "This notification option isn't available in your makerspace."); return } await write(.init(feature: cell.feature, channel: cell.channel, enabled: enabled)) } func digest(_ cell: Components.Schemas.PreferenceCell, enabled: Bool, now: Date = Date()) async { diff --git a/SpaceWorks/MemberSettingsViews.swift b/SpaceWorks/MemberSettingsViews.swift index 85de433..dcc4e07 100644 --- a/SpaceWorks/MemberSettingsViews.swift +++ b/SpaceWorks/MemberSettingsViews.swift @@ -18,10 +18,10 @@ struct ProfileEditorView: View { Text("Edit profile").font(.largeTitle.bold()) Text("Your contact details in this makerspace. Changing a phone number here doesn't verify it for sign-in.").foregroundStyle(Color.swMuted) if state.loaded { - field("Display name", value: $state.displayName, identifier: "profile-display-name", content: .name) - field("First name", value: $state.firstName, identifier: "profile-first-name", content: .givenName) - field("Last name", value: $state.lastName, identifier: "profile-last-name", content: .familyName) - field("Phone number", value: $state.phone, identifier: "profile-phone", content: .telephoneNumber) + field(String(localized: "Display name"), value: $state.displayName, identifier: "profile-display-name", content: .name) + field(String(localized: "First name"), value: $state.firstName, identifier: "profile-first-name", content: .givenName) + field(String(localized: "Last name"), value: $state.lastName, identifier: "profile-last-name", content: .familyName) + field(String(localized: "Phone number"), value: $state.phone, identifier: "profile-phone", content: .telephoneNumber) if state.dirty { Text("Unsaved changes").font(.caption).foregroundStyle(Color.swMuted) } else if let notice = state.notice { Label(notice, systemImage: "checkmark.circle").foregroundStyle(Color.swAccentInk) } if let error = state.error { SWError(message: error) } @@ -76,7 +76,7 @@ struct CommunicationPreferencesView: View { } else if state.cells.isEmpty { SWCard { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } } ForEach(Array(state.cells.enumerated()), id: \.offset) { _, cell in SWCard { - Text(cell.feature.rawValue.replacingOccurrences(of: "_", with: " ").capitalized).font(.headline) + Text(NativeStatusCopy.label(cell.feature.rawValue)).font(.headline) TimelineView(.periodic(from: .now, by: 1)) { tick in Toggle(channel(cell.channel.rawValue), isOn: Binding(get: { cell.enabled }, set: { value in Task { await state.set(cell, enabled: value) } })) .accessibilityIdentifier("preference-\(cell.feature.rawValue)-\(cell.channel.rawValue)") @@ -88,7 +88,7 @@ struct CommunicationPreferencesView: View { } if !cell.available { Text("Unavailable in this makerspace.").font(.caption).foregroundStyle(Color.swMuted) } if !cell.mandatory_events.isEmpty { - Text("Always-on safety messages: " + cell.mandatory_events.map { $0.replacingOccurrences(of: "_", with: " ") }.joined(separator: ", ")).font(.caption).foregroundStyle(Color.swMuted) + Text("Always-on safety messages: \(cell.mandatory_events.map(NativeStatusCopy.label).joined(separator: ", "))").font(.caption).foregroundStyle(Color.swMuted) } } } @@ -96,6 +96,6 @@ struct CommunicationPreferencesView: View { }.background(Color.swBg).navigationTitle("Communication").task(id: digestsEnabled) { state.digestsAllowed = digestsEnabled; await state.load() } } private func channel(_ value: String) -> String { - switch value { case "email": return "Email"; case "in_app": return "In-app inbox"; case "native_push": return "Push notifications"; default: return value } + switch value { case "email": return String(localized: "Email"); case "in_app": return String(localized: "In-app inbox"); case "native_push": return String(localized: "Push notifications"); default: return value } } } diff --git a/SpaceWorks/MemberState.swift b/SpaceWorks/MemberState.swift index 1323ebe..8c91859 100644 --- a/SpaceWorks/MemberState.swift +++ b/SpaceWorks/MemberState.swift @@ -221,7 +221,7 @@ final class MemberState { guard let components = URLComponents(string: next), let value = components.queryItems?.first(where: { $0.name == "page" })?.value, let page = Int(value), page > current else { - throw APIError(status: 0, code: "invalid_next_page", detail: "The server returned an invalid next page.") + throw APIError(status: 0, code: "invalid_next_page", detail: String(localized: "The server returned an invalid next page.")) } return page } diff --git a/SpaceWorks/MemberTicketState.swift b/SpaceWorks/MemberTicketState.swift index 3cee929..dc788da 100644 --- a/SpaceWorks/MemberTicketState.swift +++ b/SpaceWorks/MemberTicketState.swift @@ -22,7 +22,7 @@ import SpaceWorksAPI do { let data = try await repository.eventQR(registration: registration.registration_id) guard active, !cancelled, expected == generation else { return } - guard let rendered = NativeQRSVG.image(data) else { error = "This ticket image could not be opened. Try again or ask the event desk for help."; return } + guard let rendered = NativeQRSVG.image(data) else { error = String(localized: "This ticket image could not be opened. Try again or ask the event desk for help."); return } image = rendered; retryAfter = nil } catch { if active, expected == generation { self.error = APIError.display(error); retryAfter = APIError.retryDeadline(error) } } } diff --git a/SpaceWorks/MemberViews.swift b/SpaceWorks/MemberViews.swift index 9116b4f..ea9d5fe 100644 --- a/SpaceWorks/MemberViews.swift +++ b/SpaceWorks/MemberViews.swift @@ -85,7 +85,7 @@ struct MemberContent: View { else { Text("On loan").foregroundStyle(Color.swMuted) } if actions != nil { Button("Renew loan") { renewal = loan }.frame(minHeight: 48).disabled(!loan.can_renew) - if !loan.can_renew { Text(loan.renewal_refusal ?? "Renewal isn't available for this loan. Ask your makerspace for help.").font(.caption).foregroundStyle(Color.swMuted) } + if !loan.can_renew { Text(loan.renewal_refusal ?? String(localized: "Renewal isn't available for this loan. Ask your makerspace for help.")).font(.caption).foregroundStyle(Color.swMuted) } } }.padding(.vertical, 8) } @@ -99,7 +99,7 @@ struct MemberContent: View { if let rows = state.activity?.request_history, !rows.isEmpty { ForEach(Array(rows.enumerated()), id: \.offset) { _, row in VStack(alignment: .leading, spacing: 8) { - Text(row.status.replacingOccurrences(of: "_", with: " ").capitalized).font(.headline) + Text(NativeStatusCopy.label(row.status)).font(.headline) Text("\(row.item_count) items · \(row.created_at.formatted(date: .abbreviated, time: .omitted))").foregroundStyle(Color.swMuted) } } @@ -117,7 +117,7 @@ struct MemberContent: View { if actions != nil { NavigationLink(booking.space_name, value: MemberRoute.booking(booking.public_token)).font(.headline).frame(minHeight: 48) } else { Text(booking.space_name).font(.headline) } Text(memberDate(booking.starts_at, timezone: booking.timezone)) - Text(booking.status.value1.rawValue.replacingOccurrences(of: "_", with: " ").capitalized).foregroundStyle(Color.swMuted) + Text(NativeStatusCopy.label(booking.status.value1.rawValue)).foregroundStyle(Color.swMuted) }.padding(.vertical, 8) } if state.bookingsHasMore { Button("Load more bookings") { Task { await state.loadBookings(more: true) } }.frame(minHeight: 48).disabled(state.bookingsLoad == .loading) } @@ -145,7 +145,7 @@ struct MemberContent: View { ForEach(rows, id: \.registration_id) { row in VStack(alignment: .leading, spacing: 8) { NavigationLink(row.event_title, value: MemberRoute.registration(row.registration_id)).font(.headline).frame(minHeight: 48).accessibilityIdentifier("registration-\(row.registration_id)") - Text(row.status.replacingOccurrences(of: "_", with: " ").capitalized).foregroundStyle(Color.swMuted) + Text(NativeStatusCopy.label(row.status)).foregroundStyle(Color.swMuted) Text(row.starts_at.formatted(date: .abbreviated, time: .shortened)).foregroundStyle(Color.swMuted) } } @@ -249,7 +249,7 @@ struct InventoryList: View { ForEach(state.categories, id: \.id) { category in Button(category.name) { state.category = category.slug; Task { await state.loadInventory() } } } - } label: { Label(state.categories.first(where: { $0.slug == state.category })?.name ?? "All categories", systemImage: "line.3.horizontal.decrease.circle") }.frame(minHeight: 48) + } label: { Label(state.categories.first(where: { $0.slug == state.category })?.name ?? String(localized: "All categories"), systemImage: "line.3.horizontal.decrease.circle") }.frame(minHeight: 48) if case .failed = state.categoriesLoad { LoadFeedback(load: state.categoriesLoad, retryAfter: state.deadline("categories")) { await state.loadCategories() } } @@ -293,9 +293,9 @@ struct InventoryList: View { } private func availability(_ product: Components.Schemas.PublicProduct) -> String { - guard let value = product.availability else { return "Ask your makerspace about availability." } - if let count = value.count, value.mode == .exact_count { return "\(count) available" } - return value.label?.rawValue ?? "Ask your makerspace about availability." + guard let value = product.availability else { return String(localized: "Ask your makerspace about availability.") } + if let count = value.count, value.mode == .exact_count { return String(localized: "\(count) available") } + return value.label?.rawValue ?? String(localized: "Ask your makerspace about availability.") } private func memberDate(_ date: Date, timezone: String) -> String { diff --git a/SpaceWorks/MobileLinkDestination.swift b/SpaceWorks/MobileLinkDestination.swift index 701e46f..886bcd2 100644 --- a/SpaceWorks/MobileLinkDestination.swift +++ b/SpaceWorks/MobileLinkDestination.swift @@ -74,7 +74,7 @@ import SpaceWorksAPI } catch { router.destinationFailed(value, error: error) } } private static func root(_ tab: AppTab, _ navigation: MemberNavigation) { navigation.path(tab).wrappedValue = []; navigation.selected = tab } - private static var missing: APIError { APIError(status: 404, code: "link_item_unavailable", detail: "This item is no longer available. Open your inbox for the latest update.") } + private static var missing: APIError { APIError(status: 404, code: "link_item_unavailable", detail: String(localized: "This item is no longer available. Open your inbox for the latest update.")) } private static func available(_ member: MemberState, _ role: String) throws { guard member.canLoad(role) else { throw unavailable(deadline: member.deadline(role)) } } @@ -84,6 +84,6 @@ import SpaceWorksAPI throw unavailable("This update is still loading. Try again in a moment.", deadline: deadline) } private static func unavailable(_ message: String? = nil, deadline: Date? = nil) -> APIError { - APIError(status: deadline == nil ? 0 : 429, code: "link_unavailable", detail: message ?? "Couldn't open this update. Try again.", retryAfter: deadline) + APIError(status: deadline == nil ? 0 : 429, code: "link_unavailable", detail: message ?? String(localized: "Couldn't open this update. Try again."), retryAfter: deadline) } } diff --git a/SpaceWorks/MobileLinkRouter.swift b/SpaceWorks/MobileLinkRouter.swift index db307c4..02f2c15 100644 --- a/SpaceWorks/MobileLinkRouter.swift +++ b/SpaceWorks/MobileLinkRouter.swift @@ -27,7 +27,7 @@ import SpaceWorksAPI private var select: ((Int) async throws -> Void)? private var resolveLegacy: ((MobileLink.Legacy) async throws -> MobileLink)? func cancelIntent() { pendingNeedsInbox = false; revision += 1; pending = nil; pendingLegacy = nil; approved = nil; error = nil } - func refuseURL() { cancelIntent(); error = "That link can't be opened in this app." } + func refuseURL() { cancelIntent(); error = String(localized: "That link can't be opened in this app.") } func receive(_ hint: NotificationHint) { if hint.version == 2, let path = hint.relativeLink, let legacy = MobileLink.legacy(path) { receive(MobileLink.Legacy(slug: legacy.slug, staffInbox: legacy.staffInbox, expectedMakerspaceID: hint.makerspaceID)) @@ -48,7 +48,7 @@ import SpaceWorksAPI do { let intent: MobileLink if let legacy = pendingLegacy { - guard let resolveLegacy else { throw APIError(status: 403, code: "link_unavailable", detail: "This update isn't available to your account.") } + guard let resolveLegacy else { throw APIError(status: 403, code: "link_unavailable", detail: String(localized: "This update isn't available to your account.")) } let resolved = try await resolveLegacy(legacy) if let expected = legacy.expectedMakerspaceID, resolved.makerspaceID != expected { intent = MobileLink(makerspaceID: expected, destination: .inbox) @@ -59,7 +59,7 @@ import SpaceWorksAPI let membershipIDs = try await refresh() guard self.account == account, bindingRevision == expectedBinding else { continue } if revision != expected { continue } - guard membershipIDs.contains(intent.makerspaceID) else { throw APIError(status: 403, code: "link_membership_unavailable", detail: "This makerspace isn't available to your account.") } + guard membershipIDs.contains(intent.makerspaceID) else { throw APIError(status: 403, code: "link_membership_unavailable", detail: String(localized: "This makerspace isn't available to your account.")) } try await select(intent.makerspaceID) guard self.account == account, bindingRevision == expectedBinding else { continue } if revision != expected { continue } diff --git a/SpaceWorks/NativeAttestor.swift b/SpaceWorks/NativeAttestor.swift index 7eff274..b069511 100644 --- a/SpaceWorks/NativeAttestor.swift +++ b/SpaceWorks/NativeAttestor.swift @@ -17,7 +17,7 @@ enum NativeAttestor { #else let service = DCAppAttestService.shared guard service.isSupported else { - throw APIError(status: 0, code: "attestation_unavailable", detail: "This device cannot verify this app. Try a supported iPhone.") + throw APIError(status: 0, code: "attestation_unavailable", detail: String(localized: "This device cannot verify this app. Try a supported iPhone.")) } let key = try await service.generateKey() let proof = try await service.attestKey(key, clientDataHash: Data(SHA256.hash(data: Data(challenge.utf8)))) diff --git a/SpaceWorks/NativeStatusCopy.swift b/SpaceWorks/NativeStatusCopy.swift new file mode 100644 index 0000000..223083a --- /dev/null +++ b/SpaceWorks/NativeStatusCopy.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Local labels for known contract values. Unknown server values stay verbatim. +enum NativeStatusCopy { + static func label(_ raw: String) -> String { + switch raw { + case "active": return String(localized: "Active") + case "pending": return String(localized: "Pending") + case "closed": return String(localized: "Closed") + case "cancelled": return String(localized: "Cancelled") + case "approved": return String(localized: "Approved") + case "rejected": return String(localized: "Rejected") + case "returned": return String(localized: "Returned") + case "issued": return String(localized: "Issued") + case "accepted": return String(localized: "Accepted") + case "overdue": return String(localized: "Overdue") + case "registered": return String(localized: "Registered") + case "waitlisted": return String(localized: "Waitlisted") + case "pending_approval": return String(localized: "Pending approval") + case "pending_payment": return String(localized: "Pending payment") + case "paid": return String(localized: "Paid") + case "paid_online": return String(localized: "Paid online") + case "paid_offline": return String(localized: "Paid at the desk") + case "payment_pending": return String(localized: "Payment pending") + case "failed": return String(localized: "Failed") + case "processing": return String(localized: "Processing") + case "refunded": return String(localized: "Refunded") + case "partially_refunded": return String(localized: "Partially refunded") + case "unpaid": return String(localized: "Unpaid") + case "running": return String(localized: "Running") + case "available": return String(localized: "Available") + case "expired": return String(localized: "Expired") + case "revoked": return String(localized: "Revoked") + case "not_issued": return String(localized: "Not issued") + case "pending_review": return String(localized: "Pending review") + case "google": return String(localized: "Google") + case "apple": return String(localized: "Apple") + case "android": return String(localized: "Android") + case "ios": return String(localized: "iOS") + case "web": return String(localized: "Web") + case "hardware_requests": return String(localized: "Hardware requests") + case "events": return String(localized: "Events") + case "bookings": return String(localized: "Bookings") + case "membership": return String(localized: "Membership") + case "notifications": return String(localized: "Notifications") + case "loan_overdue": return String(localized: "Overdue loans") + case "Available": return String(localized: "Available") + case "Limited": return String(localized: "Limited") + case "Full": return String(localized: "Full") + default: return raw + } + } +} diff --git a/SpaceWorks/PasswordEntry.swift b/SpaceWorks/PasswordEntry.swift index 881a1a2..84df8bf 100644 --- a/SpaceWorks/PasswordEntry.swift +++ b/SpaceWorks/PasswordEntry.swift @@ -7,7 +7,7 @@ struct PasswordEntry: UIViewRepresentable { var visible = false var newPassword = false var identifier: String - var label = "Password" + var label = String(localized: "Password") var focus: Binding? var onSubmit: () -> Void = {} @@ -81,7 +81,7 @@ struct PasswordControl: View { Button { visible.toggle() } label: { Image(systemName: visible ? "eye.slash" : "eye").frame(width: 48, height: 48) }.accessibilityLabel(visible ? "Hide password" : "Show password") - .accessibilityValue(visible ? "Visible" : "Hidden") + .accessibilityValue(visible ? String(localized: "Visible") : String(localized: "Hidden")) }.padding(.leading, 12).background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)) } diff --git a/SpaceWorks/PaymentsDemo.swift b/SpaceWorks/PaymentsDemo.swift index 907e383..58706ac 100644 --- a/SpaceWorks/PaymentsDemo.swift +++ b/SpaceWorks/PaymentsDemo.swift @@ -26,7 +26,7 @@ actor DemoPaymentRepository: DemoPaymentControlling { return [first, second] } func intent(paymentID: Int) async throws -> Components.Schemas.MobilePaymentIntentResponse { - throw APIError(status: 0, code: "demo_no_sdk", detail: "Demo mode never opens a payment provider.") + throw APIError(status: 0, code: "demo_no_sdk", detail: String(localized: "Demo mode never opens a payment provider.")) } func simulate(paymentID: Int, scenario: DemoPaymentScenario) async { guard paymentID == 501 else { return } @@ -40,7 +40,7 @@ actor DemoPaymentRepository: DemoPaymentControlling { } func documentPDF(documentID: Int) async throws -> Data { guard documentID == 902 || (confirmed && documentID == 901) else { - throw APIError(status: 404, code: "not_found", detail: "Demo document unavailable.") + throw APIError(status: 404, code: "not_found", detail: String(localized: "Demo document unavailable.")) } return await MainActor.run { let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 595, height: 842)) diff --git a/SpaceWorks/PaymentsLinkDestination.swift b/SpaceWorks/PaymentsLinkDestination.swift index 9572c32..f1343de 100644 --- a/SpaceWorks/PaymentsLinkDestination.swift +++ b/SpaceWorks/PaymentsLinkDestination.swift @@ -15,12 +15,12 @@ import SpaceWorksAPI switch state.load { case .ready: if let id { - guard state.record(id) != nil else { throw APIError(status: 404, code: "payment_link_missing", detail: "This charge is no longer available. Refresh your payments.") } + guard state.record(id) != nil else { throw APIError(status: 404, code: "payment_link_missing", detail: String(localized: "This charge is no longer available. Refresh your payments.")) } navigation.open(.payment(id), tab: .profile) } else { navigation.open(.payments, tab: .profile) } case .failed(let message): throw APIError(status: state.retryAfter > Date() ? 429 : 0, code: "payment_link_failed", detail: message, retryAfter: state.retryAfter > Date() ? state.retryAfter : nil) - default: throw APIError(status: 0, code: "payment_link_wait", detail: "Payments are still loading. Try again in a moment.") + default: throw APIError(status: 0, code: "payment_link_wait", detail: String(localized: "Payments are still loading. Try again in a moment.")) } } } diff --git a/SpaceWorks/PaymentsState.swift b/SpaceWorks/PaymentsState.swift index f5c4024..8ce979f 100644 --- a/SpaceWorks/PaymentsState.swift +++ b/SpaceWorks/PaymentsState.swift @@ -37,7 +37,7 @@ struct LivePaymentRepository: PaymentRepository { func documents(page: Int) async throws -> Components.Schemas.DocumentPage { let result = try await checked { try await service.memberPaymentDocuments(makerspaceID: makerspaceID, page: page) } if let next = try origin.nextPage(result.next), next.path != "/api/v1/member/makerspaces/\(makerspaceID)/payment-documents" { - throw APIError(status: 0, code: "invalid_next_page", detail: "The next document page is unavailable. Refresh and try again.") + throw APIError(status: 0, code: "invalid_next_page", detail: String(localized: "The next document page is unavailable. Refresh and try again.")) } return result } @@ -154,7 +154,7 @@ final class PaymentsState { guard matches(attempt) else { return nil } guard canRequest else { throw throttledCheckout } guard intent.payment_id == paymentID, !intent.client_secret.isEmpty, !intent.publishable_key.isEmpty else { - throw APIError(status: 0, code: "invalid_payment_setup", detail: "Payment setup is unavailable. Contact your makerspace.") + throw APIError(status: 0, code: "invalid_payment_setup", detail: String(localized: "Payment setup is unavailable. Contact your makerspace.")) } checkout = .presenting(paymentID) return (attempt, intent) @@ -271,7 +271,7 @@ final class PaymentsState { guard let parts = URLComponents(string: value), let items = parts.queryItems, items.count == 1, items[0].name == "page", let raw = items[0].value, !raw.isEmpty, raw.allSatisfy({ $0.isASCII && $0.isNumber }), let next = Int(raw), next > page else { - throw APIError(status: 0, code: "invalid_next_page", detail: "The next document page is unavailable. Refresh and try again.") + throw APIError(status: 0, code: "invalid_next_page", detail: String(localized: "The next document page is unavailable. Refresh and try again.")) } return next } diff --git a/SpaceWorks/PaymentsViews.swift b/SpaceWorks/PaymentsViews.swift index b6d3303..e124162 100644 --- a/SpaceWorks/PaymentsViews.swift +++ b/SpaceWorks/PaymentsViews.swift @@ -106,7 +106,7 @@ struct PaymentDetailView: View { ForEach(Array(row.refunds.enumerated()), id: \.offset) { _, refund in SWCard { PaymentMoney(amount: refund.amount, currency: refund.currency) - Text(refund.status.replacingOccurrences(of: "_", with: " ").capitalized).font(.headline) + Text(NativeStatusCopy.label(refund.status)).font(.headline) if !refund.reason.isEmpty { Text(refund.reason).foregroundStyle(Color.swMuted) } if !refund.reference.isEmpty { Text("Reference: \(refund.reference)").foregroundStyle(Color.swMuted).textSelection(.enabled) } } diff --git a/SpaceWorks/Platform/NativeFileTransfer.swift b/SpaceWorks/Platform/NativeFileTransfer.swift index f9f046f..9bf5305 100644 --- a/SpaceWorks/Platform/NativeFileTransfer.swift +++ b/SpaceWorks/Platform/NativeFileTransfer.swift @@ -4,12 +4,12 @@ enum NativeTransferError: LocalizedError, Equatable { case invalidURL, invalidInput, tooLarge, refused, unsupportedDocument, failed var errorDescription: String? { switch self { - case .invalidURL: return "This file link is unavailable. Request a new link and try again." - case .invalidInput: return "This file could not be prepared. Choose another file and try again." - case .tooLarge: return "This file exceeds the supported size. Try a smaller file or use the web console." - case .refused: return "File access was refused. Request a new link and try again." - case .unsupportedDocument: return "This document could not be opened in the app." - case .failed: return "Couldn't transfer this file. Check your connection and try again." + case .invalidURL: return String(localized: "This file link is unavailable. Request a new link and try again.") + case .invalidInput: return String(localized: "This file could not be prepared. Choose another file and try again.") + case .tooLarge: return String(localized: "This file exceeds the supported size. Try a smaller file or use the web console.") + case .refused: return String(localized: "File access was refused. Request a new link and try again.") + case .unsupportedDocument: return String(localized: "This document could not be opened in the app.") + case .failed: return String(localized: "Couldn't transfer this file. Check your connection and try again.") } } } diff --git a/SpaceWorks/Platform/NativeIdentity.swift b/SpaceWorks/Platform/NativeIdentity.swift index e6be2f9..e283027 100644 --- a/SpaceWorks/Platform/NativeIdentity.swift +++ b/SpaceWorks/Platform/NativeIdentity.swift @@ -22,7 +22,7 @@ import UIKit } static func handleGoogleCallback(_ url: URL) -> Bool { GIDSignIn.sharedInstance.handle(url) } nonisolated static var unavailable: APIError { - APIError(status: 0, code: "native_provider_unconfigured", detail: "Native sign-in is not configured for this build. Contact your makerspace.") + APIError(status: 0, code: "native_provider_unconfigured", detail: String(localized: "Native sign-in is not configured for this build. Contact your makerspace.")) } } diff --git a/SpaceWorks/Platform/NativeNotifications.swift b/SpaceWorks/Platform/NativeNotifications.swift index b2c5f22..3c35b63 100644 --- a/SpaceWorks/Platform/NativeNotifications.swift +++ b/SpaceWorks/Platform/NativeNotifications.swift @@ -59,7 +59,7 @@ final class NativeNotifications { let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) authorization = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus if granted { UIApplication.shared.registerForRemoteNotifications() } - } catch { self.error = "Notifications could not be enabled. Try again in Settings." } + } catch { self.error = String(localized: "Notifications could not be enabled. Try again in Settings.") } } func refreshAuthorization() async { @@ -77,7 +77,7 @@ final class NativeNotifications { onTokenChanged?(value) } - func registrationFailed() { error = "Push registration is unavailable. Your inbox is still available." } + func registrationFailed() { error = String(localized: "Push registration is unavailable. Your inbox is still available.") } /// Preserve a cold-launch tap until authentication and membership routing have been installed. func receive(_ hint: NotificationHint) { diff --git a/SpaceWorks/Platform/NativePrivacyShield.swift b/SpaceWorks/Platform/NativePrivacyShield.swift index 1713849..eeafdc1 100644 --- a/SpaceWorks/Platform/NativePrivacyShield.swift +++ b/SpaceWorks/Platform/NativePrivacyShield.swift @@ -52,7 +52,7 @@ import UIKit controller.view.backgroundColor = UIColor(named: "bg") ?? .systemBackground controller.view.isOpaque = true controller.view.accessibilityIdentifier = "private-app-snapshot" - controller.view.accessibilityLabel = "Space Works" + controller.view.accessibilityLabel = Brand.appName controller.view.accessibilityViewIsModal = true cover.rootViewController = controller cover.isOpaque = true diff --git a/SpaceWorks/Platform/NativeScanner.swift b/SpaceWorks/Platform/NativeScanner.swift index 985075d..79d0819 100644 --- a/SpaceWorks/Platform/NativeScanner.swift +++ b/SpaceWorks/Platform/NativeScanner.swift @@ -60,7 +60,7 @@ struct NativeQRScanner: View { private func prepareCamera() async { guard AVCaptureDevice.default(for: .video) != nil else { - message = "Camera scanning is unavailable on this device. Enter the code below." + message = String(localized: "Camera scanning is unavailable on this device. Enter the code below.") return } var allowed = AVCaptureDevice.authorizationStatus(for: .video) == .authorized @@ -69,12 +69,12 @@ struct NativeQRScanner: View { } useDataScanner = DataScannerViewController.isSupported && DataScannerViewController.isAvailable cameraReady = allowed - if !cameraReady { message = "Camera access is unavailable. Enter the code below, or allow Camera in Settings." } + if !cameraReady { message = String(localized: "Camera access is unavailable. Enter the code below, or allow Camera in Settings.") } } private func cameraUnavailable() { cameraReady = false - message = "Camera scanning is unavailable. Enter the code below." + message = String(localized: "Camera scanning is unavailable. Enter the code below.") } private func complete(_ value: String) { @@ -82,7 +82,7 @@ struct NativeQRScanner: View { let code = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !code.isEmpty, code.utf8.count <= 2048, code.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) else { - message = "Enter a valid code and try again." + message = String(localized: "Enter a valid code and try again.") return } submitted = true diff --git a/SpaceWorks/ProviderButtons.swift b/SpaceWorks/ProviderButtons.swift index 2e22c3c..c46b9e9 100644 --- a/SpaceWorks/ProviderButtons.swift +++ b/SpaceWorks/ProviderButtons.swift @@ -15,7 +15,7 @@ struct GoogleProviderButton: UIViewRepresentable { func updateUIView(_ view: GIDSignInButton, context: Context) { context.coordinator.action = action view.style = wide ? .wide : .standard - view.accessibilityLabel = "Sign in with Google" + view.accessibilityLabel = String(localized: "Sign in with Google") } func makeCoordinator() -> Coordinator { Coordinator(action: action) } final class Coordinator: NSObject { diff --git a/SpaceWorks/PushRegistrationState.swift b/SpaceWorks/PushRegistrationState.swift index 0763756..784343c 100644 --- a/SpaceWorks/PushRegistrationState.swift +++ b/SpaceWorks/PushRegistrationState.swift @@ -87,7 +87,7 @@ actor PushRegistrationCoordinator { } let registered = try await repository.register(makerspaceID: maker, token: token.lowercased(), environment: environment, authorizedDirectMembershipIDs: authorizedDirectMembershipIDs) guard registered.id > 0, registered.makerspace_id == maker, registered.provider == "apns", registered.environment == environment, registered.active else { - throw APIError(status: 0, code: "invalid_push_registration", detail: "The server could not confirm notification registration. Your inbox is still available.") + throw APIError(status: 0, code: "invalid_push_registration", detail: String(localized: "The server could not confirm notification registration. Your inbox is still available.")) } state.registrations.append(.init(makerspaceID: maker, deviceID: registered.id, tokenDigest: digest, environment: environment)) do { try save(state) } @@ -171,7 +171,7 @@ actor PushRegistrationCoordinator { static func validToken(_ token: String) -> Bool { token.utf8.count >= 20 && token.utf8.count <= 4096 && token.count.isMultiple(of: 2) && token.allSatisfy { "0123456789abcdefABCDEF".contains($0) } } - static var invalidConfiguration: APIError { APIError(status: 0, code: "push_not_configured", detail: "Notifications are not configured for this build. Your inbox is still available.") } + static var invalidConfiguration: APIError { APIError(status: 0, code: "push_not_configured", detail: String(localized: "Notifications are not configured for this build. Your inbox is still available.")) } } @MainActor @Observable final class PushRegistrationState { diff --git a/SpaceWorks/PushRegistrationVault.swift b/SpaceWorks/PushRegistrationVault.swift index 3925bda..9b21198 100644 --- a/SpaceWorks/PushRegistrationVault.swift +++ b/SpaceWorks/PushRegistrationVault.swift @@ -66,7 +66,7 @@ struct PushRegistrationVault: PushRegistrationStorage { row.tokenDigest.allSatisfy { "0123456789abcdef".contains($0) } }) else { throw storageError } } - private var storageError: APIError { APIError(status: 0, code: "push_storage_unavailable", detail: "Notification settings could not be stored securely. Unlock your device and try again.") } + private var storageError: APIError { APIError(status: 0, code: "push_storage_unavailable", detail: String(localized: "Notification settings could not be stored securely. Unlock your device and try again.")) } private func check(_ status: OSStatus) throws { guard status == errSecSuccess else { throw storageError } } static func digest(_ value: String) -> String { SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() } } diff --git a/SpaceWorks/Resources/Localizable.xcstrings b/SpaceWorks/Resources/Localizable.xcstrings new file mode 100644 index 0000000..2b87531 --- /dev/null +++ b/SpaceWorks/Resources/Localizable.xcstrings @@ -0,0 +1,8146 @@ +{ + "sourceLanguage": "en", + "strings": { + " (required)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " (required)" + } + } + } + }, + " Ask your makerspace for help.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " Ask your makerspace for help." + } + } + } + }, + "A deposit is still held by the makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A deposit is still held by the makerspace." + } + } + } + }, + "A makerspace review must finish before deletion can proceed.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A makerspace review must finish before deletion can proceed." + } + } + } + }, + "A membership card hasn't been issued yet.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A membership card hasn't been issued yet." + } + } + } + }, + "A refund is still being processed.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A refund is still being processed." + } + } + } + }, + "A service job is still open.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A service job is still open." + } + } + } + }, + "A ticket is available only after registration is confirmed.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A ticket is available only after registration is confirmed." + } + } + } + }, + "About the event": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "About the event" + } + } + } + }, + "About this item": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "About this item" + } + } + } + }, + "Accept request": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accept request" + } + } + } + }, + "Accepted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accepted" + } + } + } + }, + "Account and security": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account and security" + } + } + } + }, + "Account created": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account created" + } + } + } + }, + "Account data preview": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account data preview" + } + } + } + }, + "Account deletion": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account deletion" + } + } + } + }, + "Account deletion isn't available on this server yet. Contact your makerspace for help.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account deletion isn't available on this server yet. Contact your makerspace for help." + } + } + } + }, + "Action preview result": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Action preview result" + } + } + } + }, + "Active": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Active" + } + } + } + }, + "Active loans": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Active loans" + } + } + } + }, + "Add a reason before rejecting this request.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add a reason before rejecting this request." + } + } + } + }, + "Add a reason of up to 1,000 characters.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add a reason of up to 1,000 characters." + } + } + } + }, + "Add an item before submitting your request.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add an item before submitting your request." + } + } + } + }, + "Add an item from inventory to get started.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add an item from inventory to get started." + } + } + } + }, + "Add another sign-in method before removing this one.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add another sign-in method before removing this one." + } + } + } + }, + "Add equipment": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add equipment" + } + } + } + }, + "Add this account to your authenticator app, then enter its six-digit code.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add this account to your authenticator app, then enter its six-digit code." + } + } + } + }, + "Add to request": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add to request" + } + } + } + }, + "Added to your request": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Added to your request" + } + } + } + }, + "All": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All" + } + } + } + }, + "All categories": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All categories" + } + } + } + }, + "All charges": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All charges" + } + } + } + }, + "Allow Camera in Settings to take an evidence photo.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow Camera in Settings to take an evidence photo." + } + } + } + }, + "Always-on safety messages: ": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Always-on safety messages: " + } + } + } + }, + "An accountability review needs attention.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "An accountability review needs attention." + } + } + } + }, + "An obligation changed. Review the updated blockers before continuing.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "An obligation changed. Review the updated blockers before continuing." + } + } + } + }, + "Android": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Android" + } + } + } + }, + "App data preview": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "App data preview" + } + } + } + }, + "Apple": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Apple" + } + } + } + }, + "Approve booking": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Approve booking" + } + } + } + }, + "Approved": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Approved" + } + } + } + }, + "Ask your makerspace about availability.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask your makerspace about availability." + } + } + } + }, + "Ask your makerspace for help, or check the server address.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask your makerspace for help, or check the server address." + } + } + } + }, + "Ask your makerspace for its support contact.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask your makerspace for its support contact." + } + } + } + }, + "Ask your makerspace to update staff responsibilities before deleting.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask your makerspace to update staff responsibilities before deleting." + } + } + } + }, + "Asset details are unavailable. Refresh this loan before recording a return.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Asset details are unavailable. Refresh this loan before recording a return." + } + } + } + }, + "Assign another manager before leaving this makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Assign another manager before leaving this makerspace." + } + } + } + }, + "Authenticator and recovery codes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Authenticator and recovery codes" + } + } + } + }, + "Authenticator or recovery code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Authenticator or recovery code" + } + } + } + }, + "Authenticator setup QR code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Authenticator setup QR code" + } + } + } + }, + "Available": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Available" + } + } + } + }, + "Awaiting approval": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Awaiting approval" + } + } + } + }, + "Back": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Back" + } + } + } + }, + "Back to demo": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to demo" + } + } + } + }, + "Back to sign in": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to sign in" + } + } + } + }, + "Book a space": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Book a space" + } + } + } + }, + "Book another slot": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Book another slot" + } + } + } + }, + "Booked times": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Booked times" + } + } + } + }, + "Booking approvals": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Booking approvals" + } + } + } + }, + "Booking approved.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Booking approved." + } + } + } + }, + "Booking details": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Booking details" + } + } + } + }, + "Booking reference": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Booking reference" + } + } + } + }, + "Booking rejected.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Booking rejected." + } + } + } + }, + "Bookings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bookings" + } + } + } + }, + "Borrow hardware. Make room for your next idea.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Borrow hardware. Make room for your next idea." + } + } + } + }, + "Borrow with confidence.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Borrow with confidence." + } + } + } + }, + "Borrower": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Borrower" + } + } + } + }, + "Borrowing terms": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Borrowing terms" + } + } + } + }, + "Box assignment was recorded; issue still needs to complete. Check the latest request before retrying.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Box assignment was recorded; issue still needs to complete. Check the latest request before retrying." + } + } + } + }, + "Browse inventory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browse inventory" + } + } + } + }, + "Calling code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Calling code" + } + } + } + }, + "Camera access is restricted on this device. Ask an administrator or use another device.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Camera access is restricted on this device. Ask an administrator or use another device." + } + } + } + }, + "Camera access is unavailable. Enter the code below, or allow Camera in Settings.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Camera access is unavailable. Enter the code below, or allow Camera in Settings." + } + } + } + }, + "Camera request cancelled.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Camera request cancelled." + } + } + } + }, + "Camera scanning is unavailable on this device. Enter the code below.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Camera scanning is unavailable on this device. Enter the code below." + } + } + } + }, + "Camera scanning is unavailable. Enter the code below.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Camera scanning is unavailable. Enter the code below." + } + } + } + }, + "Can't scan? Enter this key": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Can't scan? Enter this key" + } + } + } + }, + "Cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + } + } + }, + "Cancel booking": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel booking" + } + } + } + }, + "Cancel membership renewal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel membership renewal" + } + } + } + }, + "Cancel or complete upcoming bookings.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel or complete upcoming bookings." + } + } + } + }, + "Cancel or complete upcoming event registrations.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel or complete upcoming event registrations." + } + } + } + }, + "Cancel registration": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel registration" + } + } + } + }, + "Canceled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Canceled" + } + } + } + }, + "Cancellation isn't available for this booking.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancellation isn't available for this booking." + } + } + } + }, + "Cancelled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancelled" + } + } + } + }, + "Change email": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Change email" + } + } + } + }, + "Change number": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Change number" + } + } + } + }, + "Change password": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Change password" + } + } + } + }, + "Change server": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Change server" + } + } + } + }, + "Changing your authenticator or recovery codes ends your existing sessions.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Changing your authenticator or recovery codes ends your existing sessions." + } + } + } + }, + "Charge waived": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Charge waived" + } + } + } + }, + "Check export status": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check export status" + } + } + } + }, + "Check handover code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check handover code" + } + } + } + }, + "Check payment status": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check payment status" + } + } + } + }, + "Check that these counts do not exceed the remaining quantity.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check that these counts do not exceed the remaining quantity." + } + } + } + }, + "Check the borrower's membership card or approved identification before handing over the equipment.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check the borrower's membership card or approved identification before handing over the equipment." + } + } + } + }, + "Check the borrower, equipment, counts, and evidence before recording the handover.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check the borrower, equipment, counts, and evidence before recording the handover." + } + } + } + }, + "Check the highlighted questions.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check the highlighted questions." + } + } + } + }, + "Check the items and quantities. Your makerspace confirms availability and borrowing terms.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check the items and quantities. Your makerspace confirms availability and borrowing terms." + } + } + } + }, + "Check the latest request before starting another handover. A failed response may need reconciliation.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check the latest request before starting another handover. A failed response may need reconciliation." + } + } + } + }, + "Check your contact details and codes, then try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check your contact details and codes, then try again." + } + } + } + }, + "Check your email for a verification code.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check your email for a verification code." + } + } + } + }, + "Check-in": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check-in" + } + } + } + }, + "Check-in code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check-in code" + } + } + } + }, + "Checking payment…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking payment…" + } + } + } + }, + "Checking registration…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking registration…" + } + } + } + }, + "Checkout is open…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Checkout is open…" + } + } + } + }, + "Choose Yes or No.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose Yes or No." + } + } + } + }, + "Choose a JPEG photo up to 5 MB.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a JPEG photo up to 5 MB." + } + } + } + }, + "Choose a borrower": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a borrower" + } + } + } + }, + "Choose a borrower, equipment, and evidence": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a borrower, equipment, and evidence" + } + } + } + }, + "Choose a linked sign-in provider.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a linked sign-in provider." + } + } + } + }, + "Choose a makerspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a makerspace" + } + } + } + }, + "Choose a makerspace to continue.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a makerspace to continue." + } + } + } + }, + "Choose a new password": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a new password" + } + } + } + }, + "Choose a ticket type": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a ticket type" + } + } + } + }, + "Choose a ticket type.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a ticket type." + } + } + } + }, + "Choose a unique password. Your server checks its password policy.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a unique password. Your server checks its password policy." + } + } + } + }, + "Choose a valid account record.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a valid account record." + } + } + } + }, + "Choose a valid quantity for at least one item.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a valid quantity for at least one item." + } + } + } + }, + "Choose a valid session.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a valid session." + } + } + } + }, + "Choose an active makerspace membership to use this account option.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose an active makerspace membership to use this account option." + } + } + } + }, + "Choose an available ticket type.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose an available ticket type." + } + } + } + }, + "Choose an eligible borrower": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose an eligible borrower" + } + } + } + }, + "Choose an end time in the future and after the start time.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose an end time in the future and after the start time." + } + } + } + }, + "Choose an option": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose an option" + } + } + } + }, + "Choose one of the available options.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose one of the available options." + } + } + } + }, + "Choose optional updates for this makerspace. Required safety messages stay on. Device notification permission is a separate setting.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose optional updates for this makerspace. Required safety messages stay on. Device notification permission is a separate setting." + } + } + } + }, + "Choose up to 99 of each item.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose up to 99 of each item." + } + } + } + }, + "Choose which updates you receive in Communication preferences. Required safety messages remain available in your inbox.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose which updates you receive in Communication preferences. Required safety messages remain available in your inbox." + } + } + } + }, + "Clear filters": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear filters" + } + } + } + }, + "Close": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close" + } + } + } + }, + "Close and refresh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close and refresh" + } + } + } + }, + "Closed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Closed" + } + } + } + }, + "Code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Code" + } + } + } + }, + "Communication": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Communication" + } + } + } + }, + "Communication preferences": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Communication preferences" + } + } + } + }, + "Confirm acceptance": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm acceptance" + } + } + } + }, + "Confirm attendance": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm attendance" + } + } + } + }, + "Confirm deletion": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm deletion" + } + } + } + }, + "Confirm email": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm email" + } + } + } + }, + "Confirm password": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm password" + } + } + } + }, + "Confirm rejection": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm rejection" + } + } + } + }, + "Confirm your email": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm your email" + } + } + } + }, + "Confirming records this handover. Review the details before continuing.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirming records this handover. Review the details before continuing." + } + } + } + }, + "Connect a new provider in your makerspace's web portal. Your existing sign-in methods remain available here.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect a new provider in your makerspace's web portal. Your existing sign-in methods remain available here." + } + } + } + }, + "Connect to your makerspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect to your makerspace" + } + } + } + }, + "Connected sign-in methods": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connected sign-in methods" + } + } + } + }, + "Contact your makerspace staff to reset your password.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Contact your makerspace staff to reset your password." + } + } + } + }, + "Continue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Continue" + } + } + } + }, + "Continue demo account": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Continue demo account" + } + } + } + }, + "Continue to payment": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Continue to payment" + } + } + } + }, + "Continue to sign in": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Continue to sign in" + } + } + } + }, + "Copy setup key": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy setup key" + } + } + } + }, + "Couldn't check this payment. Refresh its status before trying again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't check this payment. Refresh its status before trying again." + } + } + } + }, + "Couldn't connect. Try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't connect. Try again." + } + } + } + }, + "Couldn't load sign-in options. Try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load sign-in options. Try again." + } + } + } + }, + "Couldn't open this update. Try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't open this update. Try again." + } + } + } + }, + "Couldn't transfer this file. Check your connection and try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't transfer this file. Check your connection and try again." + } + } + } + }, + "Count each component. Enter zero for a missing component.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Count each component. Enter zero for a missing component." + } + } + } + }, + "Country calling code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Country calling code" + } + } + } + }, + "Country code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Country code" + } + } + } + }, + "Create account": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create account" + } + } + } + }, + "Create your account": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create your account" + } + } + } + }, + "Current password": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Current password" + } + } + } + }, + "Daily email digest": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Daily email digest" + } + } + } + }, + "Damaged": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Damaged" + } + } + } + }, + "Data export": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Data export" + } + } + } + }, + "Delete account": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete account" + } + } + } + }, + "Delete demo account": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete demo account" + } + } + } + }, + "Deletion preview": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Deletion preview" + } + } + } + }, + "Demo account cleared": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo account cleared" + } + } + } + }, + "Demo account · sample data": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo account · sample data" + } + } + } + }, + "Demo code ready. Use 123456. No email was sent.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo code ready. Use 123456. No email was sent." + } + } + } + }, + "Demo document unavailable.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo document unavailable." + } + } + } + }, + "Demo documents · sample data": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo documents · sample data" + } + } + } + }, + "Demo handover · no live equipment changes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo handover · no live equipment changes" + } + } + } + }, + "Demo linking cancelled.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo linking cancelled." + } + } + } + }, + "Demo mode never opens a payment provider.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo mode never opens a payment provider." + } + } + } + }, + "Demo mode · no account changes are made": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo mode · no account changes are made" + } + } + } + }, + "Demo mode · no device permission, push token, or notification is sent.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo mode · no device permission, push token, or notification is sent." + } + } + } + }, + "Demo mode · no real booking is made": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo mode · no real booking is made" + } + } + } + }, + "Demo mode · no real event registration": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo mode · no real event registration" + } + } + } + }, + "Demo mode · no real loan is changed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo mode · no real loan is changed" + } + } + } + }, + "Demo mode · no real preferences are changed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo mode · no real preferences are changed" + } + } + } + }, + "Demo mode · no real request is submitted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo mode · no real request is submitted" + } + } + } + }, + "Demo mode · sample booking": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo mode · sample booking" + } + } + } + }, + "Demo mode · sample data": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo mode · sample data" + } + } + } + }, + "Demo mode · sample event ticket": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo mode · sample event ticket" + } + } + } + }, + "Demo mode · sample profile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo mode · sample profile" + } + } + } + }, + "Demo mode · sample spaces": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo mode · sample spaces" + } + } + } + }, + "Demo mode. No provider account is connected and no message is sent.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo mode. No provider account is connected and no message is sent." + } + } + } + }, + "Demo mode. No provider account is connected.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo mode. No provider account is connected." + } + } + } + }, + "Demo notification settings refreshed. No notifications were sent.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo notification settings refreshed. No notifications were sent." + } + } + } + }, + "Demo only · no account will be deleted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo only · no account will be deleted" + } + } + } + }, + "Demo only. Changes stay in this preview. No notifications will be sent.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo only. Changes stay in this preview. No notifications will be sent." + } + } + } + }, + "Demo only. Use DemoPass!123 as the current password. No password will be changed.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo only. Use DemoPass!123 as the current password. No password will be changed." + } + } + } + }, + "Demo only. Use sample as the current password. No password will be changed.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo only. Use sample as the current password. No password will be changed." + } + } + } + }, + "Demo payments · sample data": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo payments · sample data" + } + } + } + }, + "Demo role": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo role" + } + } + } + }, + "Demo staff tools · sample data": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo staff tools · sample data" + } + } + } + }, + "Describe the equipment condition": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Describe the equipment condition" + } + } + } + }, + "Device notifications could not be updated. Try again before leaving.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Device notifications could not be updated. Try again before leaving." + } + } + } + }, + "Device signed out.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Device signed out." + } + } + } + }, + "Devices": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Devices" + } + } + } + }, + "Direct handout": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Direct handout" + } + } + } + }, + "Disable two-step sign-in": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable two-step sign-in" + } + } + } + }, + "Discard changes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Discard changes" + } + } + } + }, + "Dismiss": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dismiss" + } + } + } + }, + "Display name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Display name" + } + } + } + }, + "Documents": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Documents" + } + } + } + }, + "Done": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + } + } + }, + "Download export": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download export" + } + } + } + }, + "Due date": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Due date" + } + } + } + }, + "Due today": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Due today" + } + } + } + }, + "Dues": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dues" + } + } + } + }, + "Dues are unavailable. Try again later.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dues are unavailable. Try again later." + } + } + } + }, + "Each update checks demo membership and reloads sample data before opening.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Each update checks demo membership and reloads sample data before opening." + } + } + } + }, + "Edit profile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit profile" + } + } + } + }, + "Email": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Email" + } + } + } + }, + "Email (optional)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Email (optional)" + } + } + } + }, + "Email confirmed.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Email confirmed." + } + } + } + }, + "Email or username": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Email or username" + } + } + } + }, + "Enable two-step sign-in in your makerspace's web portal, then sign in again on this device.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable two-step sign-in in your makerspace's web portal, then sign in again on this device." + } + } + } + }, + "Enable two-step verification in your browser, then sign in again on this device.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable two-step verification in your browser, then sign in again on this device." + } + } + } + }, + "Enroll authenticator": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enroll authenticator" + } + } + } + }, + "Enter a date as YYYY-MM-DD.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a date as YYYY-MM-DD." + } + } + } + }, + "Enter a valid QR payload.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a valid QR payload." + } + } + } + }, + "Enter a valid check-in code.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a valid check-in code." + } + } + } + }, + "Enter a valid code and try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a valid code and try again." + } + } + } + }, + "Enter a valid email address.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a valid email address." + } + } + } + }, + "Enter a valid number.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a valid number." + } + } + } + }, + "Enter a valid phone number.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a valid phone number." + } + } + } + }, + "Enter code manually": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter code manually" + } + } + } + }, + "Enter the server address provided by your makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter the server address provided by your makerspace." + } + } + } + }, + "Enter the six-digit code sent to your email.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter the six-digit code sent to your email." + } + } + } + }, + "Enter the six-digit verification code.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter the six-digit verification code." + } + } + } + }, + "Enter your account email to request a six-digit recovery code.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter your account email to request a six-digit recovery code." + } + } + } + }, + "Enter your authenticator or recovery code.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter your authenticator or recovery code." + } + } + } + }, + "Enter your current password and a new password.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter your current password and a new password." + } + } + } + }, + "Enter your current password and matching new passwords.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter your current password and matching new passwords." + } + } + } + }, + "Enter your current password.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter your current password." + } + } + } + }, + "Enter your email or username and password.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter your email or username and password." + } + } + } + }, + "Enter your invitation code.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter your invitation code." + } + } + } + }, + "Enter your name (up to 200 characters).": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter your name (up to 200 characters)." + } + } + } + }, + "Enter your recovery code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter your recovery code" + } + } + } + }, + "Enter your verification code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter your verification code" + } + } + } + }, + "Equipment code added": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Equipment code added" + } + } + } + }, + "Event check-in": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Event check-in" + } + } + } + }, + "Event check-in QR code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Event check-in QR code" + } + } + } + }, + "Event details": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Event details" + } + } + } + }, + "Event registration": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Event registration" + } + } + } + }, + "Event registrations": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Event registrations" + } + } + } + }, + "Events": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Events" + } + } + } + }, + "Evidence photo": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Evidence photo" + } + } + } + }, + "Evidence photo preview": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Evidence photo preview" + } + } + } + }, + "Exit demo": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Exit demo" + } + } + } + }, + "Expired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expired" + } + } + } + }, + "Explore demo": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Explore demo" + } + } + } + }, + "Explore sample makerspaces and complete app flows. Nothing is sent to a server.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Explore sample makerspaces and complete app flows. Nothing is sent to a server." + } + } + } + }, + "Export requested. Check its status when you're ready.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Export requested. Check its status when you're ready." + } + } + } + }, + "Failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed" + } + } + } + }, + "File access was refused. Request a new link and try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File access was refused. Request a new link and try again." + } + } + } + }, + "Find a space": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Find a space" + } + } + } + }, + "Find this registration in Activity. Tickets appear only after registration is confirmed.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Find this registration in Activity. Tickets appear only after registration is confirmed." + } + } + } + }, + "Find tools, book a space, and see what's next at your makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Find tools, book a space, and see what's next at your makerspace." + } + } + } + }, + "Find what you need, then request it from your makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Find what you need, then request it from your makerspace." + } + } + } + }, + "Find your booking in Activity. Its reference and cancellation options are loaded from your account.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Find your booking in Activity. Its reference and cancellation options are loaded from your account." + } + } + } + }, + "First name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "First name" + } + } + } + }, + "Forgot password?": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Forgot password?" + } + } + } + }, + "Full": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Full" + } + } + } + }, + "Google": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Google" + } + } + } + }, + "Handover progress": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Handover progress" + } + } + } + }, + "Hardware requests": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hardware requests" + } + } + } + }, + "Hidden": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hidden" + } + } + } + }, + "I checked the borrower against this request": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "I checked the borrower against this request" + } + } + } + }, + "I saved my codes. Continue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "I saved my codes. Continue" + } + } + } + }, + "I saved my recovery codes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "I saved my recovery codes" + } + } + } + }, + "I've saved my recovery codes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "I've saved my recovery codes" + } + } + } + }, + "If an account matches this email, a recovery code will arrive. Delivery may take a few minutes.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "If an account matches this email, a recovery code will arrive. Delivery may take a few minutes." + } + } + } + }, + "If you don't have a current password, sign out and use Forgot password to recover access.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "If you don't have a current password, sign out and use Forgot password to recover access." + } + } + } + }, + "In a connected app, this opens notification settings for your device.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "In a connected app, this opens notification settings for your device." + } + } + } + }, + "In-app inbox": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "In-app inbox" + } + } + } + }, + "India +91": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "India +91" + } + } + } + }, + "Invalid or expired verification code.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid or expired verification code." + } + } + } + }, + "Invitation code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invitation code" + } + } + } + }, + "Issue equipment": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Issue equipment" + } + } + } + }, + "Issued": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Issued" + } + } + } + }, + "Item details": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Item details" + } + } + } + }, + "Join a makerspace to manage membership records here.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Join a makerspace to manage membership records here." + } + } + } + }, + "Keep booking": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep booking" + } + } + } + }, + "Keep bookings, event tickets, payments, and updates together.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep bookings, event tickets, payments, and updates together." + } + } + } + }, + "Keep each name to 150 characters or fewer.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep each name to 150 characters or fewer." + } + } + } + }, + "Keep editing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep editing" + } + } + } + }, + "Keep membership": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep membership" + } + } + } + }, + "Keep registration": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep registration" + } + } + } + }, + "Keep the purpose to 500 characters or fewer.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep the purpose to 500 characters or fewer." + } + } + } + }, + "Keep these codes somewhere safe. Each code can be used once.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep these codes somewhere safe. Each code can be used once." + } + } + } + }, + "Keep these somewhere safe. Each code works once.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep these somewhere safe. Each code works once." + } + } + } + }, + "Keep up with requests, bookings, and important makerspace updates. Your inbox is always available.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep up with requests, bookings, and important makerspace updates. Your inbox is always available." + } + } + } + }, + "Keep your display name to 200 characters or fewer.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep your display name to 200 characters or fewer." + } + } + } + }, + "Keep your membership details close. Ask the front desk for help if your card isn't available.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep your membership details close. Ask the front desk for help if your card isn't available." + } + } + } + }, + "Keep your phone number to 32 characters or fewer.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep your phone number to 32 characters or fewer." + } + } + } + }, + "Kit checklist": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kit checklist" + } + } + } + }, + "Last name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Last name" + } + } + } + }, + "Leave makerspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Leave makerspace" + } + } + } + }, + "Leaving ends your access to this makerspace. Outstanding loans or charges may need to be resolved first.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Leaving ends your access to this makerspace. Outstanding loans or charges may need to be resolved first." + } + } + } + }, + "Less searching.\nMore making.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Less searching.\nMore making." + } + } + } + }, + "Limited": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Limited" + } + } + } + }, + "Load kit checklist": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Load kit checklist" + } + } + } + }, + "Load more": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Load more" + } + } + } + }, + "Load more bookings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Load more bookings" + } + } + } + }, + "Load more borrowers": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Load more borrowers" + } + } + } + }, + "Load more documents": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Load more documents" + } + } + } + }, + "Load more equipment": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Load more equipment" + } + } + } + }, + "Load more events": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Load more events" + } + } + } + }, + "Load more loans": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Load more loans" + } + } + } + }, + "Load more requests": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Load more requests" + } + } + } + }, + "Load more spaces": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Load more spaces" + } + } + } + }, + "Loading account options…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading account options…" + } + } + } + }, + "Loading booking…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading booking…" + } + } + } + }, + "Loading documents…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading documents…" + } + } + } + }, + "Loading item…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading item…" + } + } + } + }, + "Loading payments…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading payments…" + } + } + } + }, + "Loading preferences…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading preferences…" + } + } + } + }, + "Loading profile…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading profile…" + } + } + } + }, + "Loading registration…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading registration…" + } + } + } + }, + "Loading your ticket…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading your ticket…" + } + } + } + }, + "Loading…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + } + } + }, + "Make room for your next idea. Availability and approvals come from your makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Make room for your next idea. Availability and approvals come from your makerspace." + } + } + } + }, + "Make room.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Make room." + } + } + } + }, + "Make sure you can still sign in another way. Your makerspace may refuse to remove your last method.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Make sure you can still sign in another way. Your makerspace may refuse to remove your last method." + } + } + } + }, + "Make yourself at home.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Make yourself at home." + } + } + } + }, + "Manage your sign-in, devices, membership, and personal data.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage your sign-in, devices, membership, and personal data." + } + } + } + }, + "Mark all as read": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mark all as read" + } + } + } + }, + "Mark as read": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mark as read" + } + } + } + }, + "Membership": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Membership" + } + } + } + }, + "Membership card": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Membership card" + } + } + } + }, + "Membership card QR code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Membership card QR code" + } + } + } + }, + "Membership renewal cancelled.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Membership renewal cancelled." + } + } + } + }, + "Membership self-service isn't available for this account.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Membership self-service isn't available for this account." + } + } + } + }, + "Messages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Messages" + } + } + } + }, + "Missing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing" + } + } + } + }, + "More account records are available in your browser. This server has not provided native pagination for them.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "More account records are available in your browser. This server has not provided native pagination for them." + } + } + } + }, + "More exports are available in your makerspace's web portal.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "More exports are available in your makerspace's web portal." + } + } + } + }, + "More sessions are available in your makerspace's web portal.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "More sessions are available in your makerspace's web portal." + } + } + } + }, + "Name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Name" + } + } + } + }, + "Native sign-in is not configured for this build. Contact your makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Native sign-in is not configured for this build. Contact your makerspace." + } + } + } + }, + "New due date": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New due date" + } + } + } + }, + "New here?": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New here?" + } + } + } + }, + "New password": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New password" + } + } + } + }, + "Next": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Next" + } + } + } + }, + "No": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No" + } + } + } + }, + "No confirmed bookings are shown in this window.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No confirmed bookings are shown in this window." + } + } + } + }, + "No connected providers.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No connected providers." + } + } + } + }, + "No devices found.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No devices found." + } + } + } + }, + "No dues outstanding.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No dues outstanding." + } + } + } + }, + "No message is sent in this demo. Use the sample recovery code 123456.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No message is sent in this demo. Use the sample recovery code 123456." + } + } + } + }, + "No message is sent in this demo. Use the sample verification code 123456.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No message is sent in this demo. Use the sample verification code 123456." + } + } + } + }, + "Not issued": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not issued" + } + } + } + }, + "Not returned now": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not returned now" + } + } + } + }, + "Nothing here yet.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nothing here yet." + } + } + } + }, + "Notices": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notices" + } + } + } + }, + "Notification settings could not be stored securely. Unlock your device and try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notification settings could not be stored securely. Unlock your device and try again." + } + } + } + }, + "Notifications": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notifications" + } + } + } + }, + "Notifications are not configured for this build. Your inbox is still available.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notifications are not configured for this build. Your inbox is still available." + } + } + } + }, + "Notifications are off for this device.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notifications are off for this device." + } + } + } + }, + "Notifications could not be enabled. Try again in Settings.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notifications could not be enabled. Try again in Settings." + } + } + } + }, + "On loan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "On loan" + } + } + } + }, + "One place for your making.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "One place for your making." + } + } + } + }, + "Online payment is unavailable. Ask your makerspace about paying at the desk.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Online payment is unavailable. Ask your makerspace about paying at the desk." + } + } + } + }, + "Only the tools allowed by your selected makerspace appear here.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Only the tools allowed by your selected makerspace appear here." + } + } + } + }, + "Open PDF": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open PDF" + } + } + } + }, + "Open check-in": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open check-in" + } + } + } + }, + "Open inventory update": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open inventory update" + } + } + } + }, + "Open or share documents issued by your makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open or share documents issued by your makerspace." + } + } + } + }, + "Open sample updates from your makerspaces. Changes stay in this preview.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open sample updates from your makerspaces. Changes stay in this preview." + } + } + } + }, + "Open system settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open system settings" + } + } + } + }, + "Open update": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open update" + } + } + } + }, + "Open update from another makerspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open update from another makerspace" + } + } + } + }, + "Opening hours": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Opening hours" + } + } + } + }, + "Opening your makerspace…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Opening your makerspace…" + } + } + } + }, + "Opening your update…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Opening your update…" + } + } + } + }, + "Overdue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Overdue" + } + } + } + }, + "Overdue loans": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Overdue loans" + } + } + } + }, + "Paid": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Paid" + } + } + } + }, + "Paid at the desk": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Paid at the desk" + } + } + } + }, + "Paid online": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Paid online" + } + } + } + }, + "Partially refunded": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Partially refunded" + } + } + } + }, + "Password": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Password" + } + } + } + }, + "Password change preview complete. No password was changed.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Password change preview complete. No password was changed." + } + } + } + }, + "Password changed.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Password changed." + } + } + } + }, + "Password recovery unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Password recovery unavailable" + } + } + } + }, + "Password updated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Password updated" + } + } + } + }, + "Password updated. Sign in again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Password updated. Sign in again." + } + } + } + }, + "Password verification failed.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Password verification failed." + } + } + } + }, + "Passwords don't match.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Passwords don't match." + } + } + } + }, + "Payment and approval are confirmed by your makerspace. This receipt does not confirm a payment.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Payment and approval are confirmed by your makerspace. This receipt does not confirm a payment." + } + } + } + }, + "Payment and loan history is kept anonymised for legal and accounting records. Leaving one makerspace is a separate action.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Payment and loan history is kept anonymised for legal and accounting records. Leaving one makerspace is a separate action." + } + } + } + }, + "Payment details": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Payment details" + } + } + } + }, + "Payment document": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Payment document" + } + } + } + }, + "Payment pending": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Payment pending" + } + } + } + }, + "Payment setup is unavailable. Contact your makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Payment setup is unavailable. Contact your makerspace." + } + } + } + }, + "Payment unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Payment unavailable" + } + } + } + }, + "Payments": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Payments" + } + } + } + }, + "Payments are still loading. Try again in a moment.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Payments are still loading. Try again in a moment." + } + } + } + }, + "Pending": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pending" + } + } + } + }, + "Pending approval": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pending approval" + } + } + } + }, + "Pending payment": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pending payment" + } + } + } + }, + "Pending review": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pending review" + } + } + } + }, + "Phone number": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Phone number" + } + } + } + }, + "Phone number (optional)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Phone number (optional)" + } + } + } + }, + "Phone number verified.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Phone number verified." + } + } + } + }, + "Phone removal wasn't confirmed. Check the current number before trying again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Phone removal wasn't confirmed. Check the current number before trying again." + } + } + } + }, + "Phone sign-in": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Phone sign-in" + } + } + } + }, + "Phone sign-in removed.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Phone sign-in removed." + } + } + } + }, + "Phone verification wasn't confirmed. Request a new code and try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Phone verification wasn't confirmed. Request a new code and try again." + } + } + } + }, + "Photograph the equipment and its condition. Avoid faces or unrelated personal information.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Photograph the equipment and its condition. Avoid faces or unrelated personal information." + } + } + } + }, + "Please sign in again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Please sign in again." + } + } + } + }, + "Preparing checkout…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preparing checkout…" + } + } + } + }, + "Preparing document…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preparing document…" + } + } + } + }, + "Preparing your archive…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preparing your archive…" + } + } + } + }, + "Preview account deletion": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview account deletion" + } + } + } + }, + "Preview an update": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview an update" + } + } + } + }, + "Preview canceled checkout": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview canceled checkout" + } + } + } + }, + "Preview confirmed payment": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview confirmed payment" + } + } + } + }, + "Preview delayed demo confirmation": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview delayed demo confirmation" + } + } + } + }, + "Preview device refresh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview device refresh" + } + } + } + }, + "Preview failed checkout": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview failed checkout" + } + } + } + }, + "Preview older notification": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview older notification" + } + } + } + }, + "Preview permission denied": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview permission denied" + } + } + } + }, + "Preview provider linking": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview provider linking" + } + } + } + }, + "Preview result": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview result" + } + } + } + }, + "Preview token rotation": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview token rotation" + } + } + } + }, + "Preview unauthorized makerspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview unauthorized makerspace" + } + } + } + }, + "Preview unavailable item": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview unavailable item" + } + } + } + }, + "Preview waiting for confirmation": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview waiting for confirmation" + } + } + } + }, + "Privacy and help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Privacy and help" + } + } + } + }, + "Processing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Processing" + } + } + } + }, + "Profile details": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Profile details" + } + } + } + }, + "Profile saved.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Profile saved." + } + } + } + }, + "Promo code (optional)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Promo code (optional)" + } + } + } + }, + "Push notifications": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Push notifications" + } + } + } + }, + "Push registration is unavailable. Your inbox is still available.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Push registration is unavailable. Your inbox is still available." + } + } + } + }, + "QR code camera": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "QR code camera" + } + } + } + }, + "Ready for issue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ready for issue" + } + } + } + }, + "Reason": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reason" + } + } + } + }, + "Reason for rejection": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reason for rejection" + } + } + } + }, + "Receipts and credit notes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Receipts and credit notes" + } + } + } + }, + "Received at the desk": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Received at the desk" + } + } + } + }, + "Record handover": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Record handover" + } + } + } + }, + "Record return": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Record return" + } + } + } + }, + "Refresh payment status": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh payment status" + } + } + } + }, + "Refresh payments": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh payments" + } + } + } + }, + "Refresh the list and choose an available item.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh the list and choose an available item." + } + } + } + }, + "Refresh the sample request.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh the sample request." + } + } + } + }, + "Refresh your payments to see the latest information.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh your payments to see the latest information." + } + } + } + }, + "Refunded": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refunded" + } + } + } + }, + "Refunds": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refunds" + } + } + } + }, + "Regenerate recovery codes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Regenerate recovery codes" + } + } + } + }, + "Register for this event": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Register for this event" + } + } + } + }, + "Registered": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Registered" + } + } + } + }, + "Registered devices": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Registered devices" + } + } + } + }, + "Registration confirmed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Registration confirmed" + } + } + } + }, + "Registration is closed.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Registration is closed." + } + } + } + }, + "Registration received": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Registration received" + } + } + } + }, + "Reject booking": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reject booking" + } + } + } + }, + "Reject request": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reject request" + } + } + } + }, + "Rejected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rejected" + } + } + } + }, + "Reload borrowers": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload borrowers" + } + } + } + }, + "Reload equipment": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload equipment" + } + } + } + }, + "Remove": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove" + } + } + } + }, + "Remove item": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove item" + } + } + } + }, + "Remove linked phone": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove linked phone" + } + } + } + }, + "Remove phone": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove phone" + } + } + } + }, + "Remove sign-in method": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove sign-in method" + } + } + } + }, + "Renew loan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Renew loan" + } + } + } + }, + "Renewal isn't available for this loan. Ask your makerspace for help.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Renewal isn't available for this loan. Ask your makerspace for help." + } + } + } + }, + "Replace your temporary password to continue.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Replace your temporary password to continue." + } + } + } + }, + "Request an export of your personal data held by this makerspace. Keep downloaded files somewhere private.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Request an export of your personal data held by this makerspace. Keep downloaded files somewhere private." + } + } + } + }, + "Request and download an export": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Request and download an export" + } + } + } + }, + "Request another code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Request another code" + } + } + } + }, + "Request data export": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Request data export" + } + } + } + }, + "Request details": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Request details" + } + } + } + }, + "Request queue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Request queue" + } + } + } + }, + "Request received": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Request received" + } + } + } + }, + "Request recovery code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Request recovery code" + } + } + } + }, + "Request reference": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Request reference" + } + } + } + }, + "Request this space": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Request this space" + } + } + } + }, + "Request up to 20 different items at once.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Request up to 20 different items at once." + } + } + } + }, + "Request what you need and keep an eye on your loans and due dates.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Request what you need and keep an eye on your loans and due dates." + } + } + } + }, + "Requests": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Requests" + } + } + } + }, + "Requests and loans": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Requests and loans" + } + } + } + }, + "Reset password": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset password" + } + } + } + }, + "Resolve open equipment requests.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resolve open equipment requests." + } + } + } + }, + "Resolve these before deleting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resolve these before deleting" + } + } + } + }, + "Retry account options": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry account options" + } + } + } + }, + "Retry opening update": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry opening update" + } + } + } + }, + "Return all borrowed equipment first.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Return all borrowed equipment first." + } + } + } + }, + "Return equipment": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Return equipment" + } + } + } + }, + "Return outstanding equipment before leaving this makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Return outstanding equipment before leaving this makerspace." + } + } + } + }, + "Returned": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Returned" + } + } + } + }, + "Review code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Review code" + } + } + } + }, + "Review details": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Review details" + } + } + } + }, + "Review every makerspace's obligations, verify your identity, then confirm. You can leave at any time before confirmation.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Review every makerspace's obligations, verify your identity, then confirm. You can leave at any time before confirmation." + } + } + } + }, + "Review payment": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Review payment" + } + } + } + }, + "Review requests and guide equipment handovers": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Review requests and guide equipment handovers" + } + } + } + }, + "Review reservations": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Review reservations" + } + } + } + }, + "Review space reservations": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Review space reservations" + } + } + } + }, + "Review the amount above. Your payment provider will open to complete checkout.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Review the amount above. Your payment provider will open to complete checkout." + } + } + } + }, + "Review this makerspace's active sessions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Review this makerspace's active sessions" + } + } + } + }, + "Review your linked providers": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Review your linked providers" + } + } + } + }, + "Review your native app sessions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Review your native app sessions" + } + } + } + }, + "Review your request": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Review your request" + } + } + } + }, + "Review your times and answers. The server checks availability again when you submit.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Review your times and answers. The server checks availability again when you submit." + } + } + } + }, + "Review, hand over, and welcome people into your makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Review, hand over, and welcome people into your makerspace." + } + } + } + }, + "Revoked": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Revoked" + } + } + } + }, + "Running": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Running" + } + } + } + }, + "Sample code sent in demo mode. No message was sent.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sample code sent in demo mode. No message was sent." + } + } + } + }, + "Sample codes for this demo. Real codes appear once after enrollment.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sample codes for this demo. Real codes appear once after enrollment." + } + } + } + }, + "Sample container or request changed.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sample container or request changed." + } + } + } + }, + "Sample request unavailable.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sample request unavailable." + } + } + } + }, + "Sample sign-in method connected.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sample sign-in method connected." + } + } + } + }, + "Save or share archive": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save or share archive" + } + } + } + }, + "Save this archive to a private location or share it with someone you trust.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save this archive to a private location or share it with someone you trust." + } + } + } + }, + "Save your recovery codes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save your recovery codes" + } + } + } + }, + "Scan a check-in code. Review the person below before recording attendance.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan a check-in code. Review the person below before recording attendance." + } + } + } + }, + "Scan check-in code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan check-in code" + } + } + } + }, + "Scan each individual asset.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan each individual asset." + } + } + } + }, + "Scan equipment code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan equipment code" + } + } + } + }, + "Scan the QR code, then review the details before confirming.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan the QR code, then review the details before confirming." + } + } + } + }, + "Scan, review the person, then confirm attendance": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan, review the person, then confirm attendance" + } + } + } + }, + "Search": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search" + } + } + } + }, + "Search inventory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search inventory" + } + } + } + }, + "See availability and borrowing terms here.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "See availability and borrowing terms here." + } + } + } + }, + "See your charges, payment status, and issued documents.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "See your charges, payment status, and issued documents." + } + } + } + }, + "Send another code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send another code" + } + } + } + }, + "Send verification code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send verification code" + } + } + } + }, + "Server address": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server address" + } + } + } + }, + "Session signed out.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Session signed out." + } + } + } + }, + "Sessions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sessions" + } + } + } + }, + "Settle outstanding charges before deleting your account.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settle outstanding charges before deleting your account." + } + } + } + }, + "Share": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Share" + } + } + } + }, + "Show helpful tips": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show helpful tips" + } + } + } + }, + "Show introduction": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show introduction" + } + } + } + }, + "Sign in": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in" + } + } + } + }, + "Sign in again to continue.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in again to continue." + } + } + } + }, + "Sign in to continue. You may need to confirm your email.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in to continue. You may need to confirm your email." + } + } + } + }, + "Sign in to your makerspace account.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in to your makerspace account." + } + } + } + }, + "Sign in with Google": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in with Google" + } + } + } + }, + "Sign out": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign out" + } + } + } + }, + "Sign out device": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign out device" + } + } + } + }, + "Sign-in couldn't be completed. Try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign-in couldn't be completed. Try again." + } + } + } + }, + "Sign-in isn't available here": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign-in isn't available here" + } + } + } + }, + "Sign-in method removed.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign-in method removed." + } + } + } + }, + "Sign-in methods": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign-in methods" + } + } + } + }, + "Sign-in preview": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign-in preview" + } + } + } + }, + "Signed out on this device. Server revocation could not be confirmed.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Signed out on this device. Server revocation could not be confirmed." + } + } + } + }, + "Signing out…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Signing out…" + } + } + } + }, + "Simulated QR · demo only": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Simulated QR · demo only" + } + } + } + }, + "Simulated account creation. No account was created on a server.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Simulated account creation. No account was created on a server." + } + } + } + }, + "Simulated password update. No real account was changed.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Simulated password update. No real account was changed." + } + } + } + }, + "Six-digit code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Six-digit code" + } + } + } + }, + "Skip introduction": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Skip introduction" + } + } + } + }, + "Space Works": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Space Works" + } + } + } + }, + "Spaces": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Spaces" + } + } + } + }, + "Staff data preview": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Staff data preview" + } + } + } + }, + "Staff tools": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Staff tools" + } + } + } + }, + "Start count": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start count" + } + } + } + }, + "Start direct handout": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start direct handout" + } + } + } + }, + "Start guided issue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start guided issue" + } + } + } + }, + "Start guided return": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start guided return" + } + } + } + }, + "Start here": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start here" + } + } + } + }, + "Status unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Status unavailable" + } + } + } + }, + "Still waiting for confirmation. You can safely leave and check again later.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Still waiting for confirmation. You can safely leave and check again later." + } + } + } + }, + "Superadmin accounts cannot sign in on a phone or tablet.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Superadmin accounts cannot sign in on a phone or tablet." + } + } + } + }, + "Tell your makerspace about your project": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tell your makerspace about your project" + } + } + } + }, + "Tell your makerspace why": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tell your makerspace why" + } + } + } + }, + "That code didn't work. Check it and try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That code didn't work. Check it and try again." + } + } + } + }, + "That code didn’t work. Try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That code didn’t work. Try again." + } + } + } + }, + "That is not a check-in code for this event.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That is not a check-in code for this event." + } + } + } + }, + "That link can't be opened in this app.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That link can't be opened in this app." + } + } + } + }, + "That password didn't work. Try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That password didn't work. Try again." + } + } + } + }, + "That verification code expired. Request another code.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That verification code expired. Request another code." + } + } + } + }, + "The makerspace is closed in this window.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The makerspace is closed in this window." + } + } + } + }, + "The next document page is unavailable. Refresh and try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The next document page is unavailable. Refresh and try again." + } + } + } + }, + "The next page is unavailable. Refresh and try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The next page is unavailable. Refresh and try again." + } + } + } + }, + "The next staff page is unavailable. Refresh and try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The next staff page is unavailable. Refresh and try again." + } + } + } + }, + "The request changed before issue completed.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The request changed before issue completed." + } + } + } + }, + "The sample evidence upload was refused.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The sample evidence upload was refused." + } + } + } + }, + "The server could not confirm notification registration. Your inbox is still available.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The server could not confirm notification registration. Your inbox is still available." + } + } + } + }, + "The server returned an invalid account page.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The server returned an invalid account page." + } + } + } + }, + "The server returned an invalid next page.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The server returned an invalid next page." + } + } + } + }, + "These are native app sessions on your account. Browser sessions are managed separately by your makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "These are native app sessions on your account. Browser sessions are managed separately by your makerspace." + } + } + } + }, + "These booking details could not be prepared. Check your answers and try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "These booking details could not be prepared. Check your answers and try again." + } + } + } + }, + "These people are supplied by this makerspace. Selecting a person does not create a sign-in account.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "These people are supplied by this makerspace. Selecting a person does not create a sign-in account." + } + } + } + }, + "These sample results do not open a payment SDK or contact a server.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "These sample results do not open a payment SDK or contact a server." + } + } + } + }, + "This account requires two-step sign-in.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This account requires two-step sign-in." + } + } + } + }, + "This action isn't available for your membership.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This action isn't available for your membership." + } + } + } + }, + "This booking can no longer be cancelled.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This booking can no longer be cancelled." + } + } + } + }, + "This booking changed. Refresh its status.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This booking changed. Refresh its status." + } + } + } + }, + "This changed. Refresh and try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This changed. Refresh and try again." + } + } + } + }, + "This charge is no longer available. Refresh your payments.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This charge is no longer available. Refresh your payments." + } + } + } + }, + "This configuration belongs to another makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This configuration belongs to another makerspace." + } + } + } + }, + "This deletes your whole Space Works account on this server, across every makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This deletes your whole Space Works account on this server, across every makerspace." + } + } + } + }, + "This device": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This device" + } + } + } + }, + "This device cannot verify this app. Try a supported iPhone.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This device cannot verify this app. Try a supported iPhone." + } + } + } + }, + "This device has no available camera. Use a device with a camera to record this handover.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This device has no available camera. Use a device with a camera to record this handover." + } + } + } + }, + "This device was signed out.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This device was signed out." + } + } + } + }, + "This document could not be opened in the app.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This document could not be opened in the app." + } + } + } + }, + "This equipment is both scanned and selected by quantity. Remove one selection before continuing.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This equipment is both scanned and selected by quantity. Remove one selection before continuing." + } + } + } + }, + "This export expired. Request a new archive.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This export expired. Request a new archive." + } + } + } + }, + "This export link is unavailable. Request a new link and try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This export link is unavailable. Request a new link and try again." + } + } + } + }, + "This file could not be prepared. Choose another file and try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This file could not be prepared. Choose another file and try again." + } + } + } + }, + "This file exceeds the supported size. Try a smaller file or use the web console.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This file exceeds the supported size. Try a smaller file or use the web console." + } + } + } + }, + "This file link is unavailable. Request a new link and try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This file link is unavailable. Request a new link and try again." + } + } + } + }, + "This form isn't supported in the app yet.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This form isn't supported in the app yet." + } + } + } + }, + "This form uses a question type the app doesn't support yet. Use the web console or ask your makerspace for help.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This form uses a question type the app doesn't support yet. Use the web console or ask your makerspace for help." + } + } + } + }, + "This item is no longer available.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This item is no longer available." + } + } + } + }, + "This item is no longer available. Open your inbox for the latest update.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This item is no longer available. Open your inbox for the latest update." + } + } + } + }, + "This loan wasn't found.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This loan wasn't found." + } + } + } + }, + "This makerspace doesn't share booked times. Your request is checked when submitted.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This makerspace doesn't share booked times. Your request is checked when submitted." + } + } + } + }, + "This makerspace isn't available to your account.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This makerspace isn't available to your account." + } + } + } + }, + "This membership change wasn't confirmed. Refresh its status before trying again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This membership change wasn't confirmed. Refresh its status before trying again." + } + } + } + }, + "This notification option isn't available in your makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This notification option isn't available in your makerspace." + } + } + } + }, + "This number could not be verified.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This number could not be verified." + } + } + } + }, + "This option isn't available in this makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This option isn't available in this makerspace." + } + } + } + }, + "This option wasn't found.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This option wasn't found." + } + } + } + }, + "This photo could not be prepared. Take another photo.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This photo could not be prepared. Take another photo." + } + } + } + }, + "This preview does not resolve real obligations. Open the matching makerspace task from your account to continue.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This preview does not resolve real obligations. Open the matching makerspace task from your account to continue." + } + } + } + }, + "This provider is unavailable. Try again later.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This provider is unavailable. Try again later." + } + } + } + }, + "This question is required.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This question is required." + } + } + } + }, + "This registration cannot be checked in. Scan another code or ask the event manager.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This registration cannot be checked in. Scan another code or ask the event manager." + } + } + } + }, + "This registration changed.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This registration changed." + } + } + } + }, + "This registration changed. Scan the code again to check its latest status.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This registration changed. Scan the code again to check its latest status." + } + } + } + }, + "This registration wasn't found.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This registration wasn't found." + } + } + } + }, + "This request changed. Review it again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This request changed. Review it again." + } + } + } + }, + "This server did not provide a device session. Contact your makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This server did not provide a device session. Contact your makerspace." + } + } + } + }, + "This session": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This session" + } + } + } + }, + "This sign-in method is already connected to another account.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This sign-in method is already connected to another account." + } + } + } + }, + "This sign-in option isn't available on this device.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This sign-in option isn't available on this device." + } + } + } + }, + "This summary reflects the recorded handover. Refresh the loan to see its latest status.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This summary reflects the recorded handover. Refresh the loan to see its latest status." + } + } + } + }, + "This ticket image could not be opened. Try again or ask the event desk for help.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This ticket image could not be opened. Try again or ask the event desk for help." + } + } + } + }, + "This update isn't available to your account.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This update isn't available to your account." + } + } + } + }, + "Ticket": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ticket" + } + } + } + }, + "Ticket type": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ticket type" + } + } + } + }, + "Too many requests. Try again later.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Too many requests. Try again later." + } + } + } + }, + "Transfer organization ownership first.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Transfer organization ownership first." + } + } + } + }, + "Try again": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Try again" + } + } + } + }, + "Turn off for this account": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn off for this account" + } + } + } + }, + "Two-step sign-in": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Two-step sign-in" + } + } + } + }, + "Two-step sign-in is unavailable on this server.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Two-step sign-in is unavailable on this server." + } + } + } + }, + "Type DELETE": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Type DELETE" + } + } + } + }, + "Type DELETE to clear this demo account. This preview only resets in-memory sample data.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Type DELETE to clear this demo account. This preview only resets in-memory sample data." + } + } + } + }, + "Type DELETE to confirm.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Type DELETE to confirm." + } + } + } + }, + "Unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unavailable" + } + } + } + }, + "Unavailable in this makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unavailable in this makerspace." + } + } + } + }, + "United Kingdom +44": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "United Kingdom +44" + } + } + } + }, + "United States +1": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "United States +1" + } + } + } + }, + "Unpaid": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unpaid" + } + } + } + }, + "Unread": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unread" + } + } + } + }, + "Unsaved changes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unsaved changes" + } + } + } + }, + "Up to 500 characters. Don't include sensitive information.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Up to 500 characters. Don't include sensitive information." + } + } + } + }, + "Upcoming": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Upcoming" + } + } + } + }, + "Upcoming bookings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Upcoming bookings" + } + } + } + }, + "Update password": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update password" + } + } + } + }, + "Update required": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update required" + } + } + } + }, + "Update the app to keep using this server. This is a preview of the server's minimum-version gate.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update the app to keep using this server. This is a preview of the server's minimum-version gate." + } + } + } + }, + "Upload evidence photo": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Upload evidence photo" + } + } + } + }, + "Use 123456 for this demo. No message or authenticator request was sent.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use 123456 for this demo. No message or authenticator request was sent." + } + } + } + }, + "Use 123456 in this demo. No message was sent.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use 123456 in this demo. No message was sent." + } + } + } + }, + "Use 123456 or sample-recovery in this demo.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use 123456 or sample-recovery in this demo." + } + } + } + }, + "Use DEMO-BOX or DEMO-TOOL in this sample.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use DEMO-BOX or DEMO-TOOL in this sample." + } + } + } + }, + "Use DemoPass!123 for this demo.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use DemoPass!123 for this demo." + } + } + } + }, + "Use DemoPass!123 in this demo. No real security setting changes.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use DemoPass!123 in this demo. No real security setting changes." + } + } + } + }, + "Use a password with no more than 128 characters.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use a password with no more than 128 characters." + } + } + } + }, + "Use a phone number with no more than 32 characters.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use a phone number with no more than 32 characters." + } + } + } + }, + "Use an authenticator as an extra check when you sign in.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use an authenticator as an extra check when you sign in." + } + } + } + }, + "Use any email and password in this demo.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use any email and password in this demo." + } + } + } + }, + "Use at least 8 characters.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use at least 8 characters." + } + } + } + }, + "Use sample evidence photo": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use sample evidence photo" + } + } + } + }, + "Use the email you want to use with your makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use the email you want to use with your makerspace." + } + } + } + }, + "Use the web console": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use the web console" + } + } + } + }, + "Use the web console to resolve server administration responsibilities.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use the web console to resolve server administration responsibilities." + } + } + } + }, + "Use unverified to preview email confirmation.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use unverified to preview email confirmation." + } + } + } + }, + "Use your current password to set a new one": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use your current password to set a new one" + } + } + } + }, + "Verification code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verification code" + } + } + } + }, + "Verification code sent.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verification code sent." + } + } + } + }, + "Verification method": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verification method" + } + } + } + }, + "Verify": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verify" + } + } + } + }, + "Verify a number to use it for sign-in.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verify a number to use it for sign-in." + } + } + } + }, + "Verify identity": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verify identity" + } + } + } + }, + "Verify or remove a phone number": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verify or remove a phone number" + } + } + } + }, + "Verify phone number": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verify phone number" + } + } + } + }, + "Verify your identity": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verify your identity" + } + } + } + }, + "Verify your identity again before deleting.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verify your identity again before deleting." + } + } + } + }, + "View availability": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View availability" + } + } + } + }, + "View bookings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View bookings" + } + } + } + }, + "View membership card": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View membership card" + } + } + } + }, + "View registrations": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View registrations" + } + } + } + }, + "Visible": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Visible" + } + } + } + }, + "Waitlisted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waitlisted" + } + } + } + }, + "We'll send a verification code.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "We'll send a verification code." + } + } + } + }, + "Web": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Web" + } + } + } + }, + "Welcome back.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Welcome back." + } + } + } + }, + "What are you making? (optional)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "What are you making? (optional)" + } + } + } + }, + "Working…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Working…" + } + } + } + }, + "Yes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Yes" + } + } + } + }, + "You haven’t joined a makerspace yet.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You haven’t joined a makerspace yet." + } + } + } + }, + "You no longer have permission for this handover.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You no longer have permission for this handover." + } + } + } + }, + "You'll need to sign in again to use this account.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You'll need to sign in again to use this account." + } + } + } + }, + "You're on the waitlist": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You're on the waitlist" + } + } + } + }, + "Your access changed. Return to your makerspace before continuing.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your access changed. Return to your makerspace before continuing." + } + } + } + }, + "Your account": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your account" + } + } + } + }, + "Your account requires two-step sign-in.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your account requires two-step sign-in." + } + } + } + }, + "Your account still requires a password update. Try again or contact your makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your account still requires a password update. Try again or contact your makerspace." + } + } + } + }, + "Your card QR code isn't available. Ask the front desk for help.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your card QR code isn't available. Ask the front desk for help." + } + } + } + }, + "Your contact details in this makerspace. Changing a phone number here doesn't verify it for sign-in.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your contact details in this makerspace. Changing a phone number here doesn't verify it for sign-in." + } + } + } + }, + "Your data": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your data" + } + } + } + }, + "Your existing sessions will end after this change.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your existing sessions will end after this change." + } + } + } + }, + "Your makerspace checks its renewal policy. A renewal is confirmed only when a new due date is returned.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your makerspace checks its renewal policy. A renewal is confirmed only when a new due date is returned." + } + } + } + }, + "Your makerspace checks the cancellation cutoff. A cancellation does not automatically refund a settled payment.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your makerspace checks the cancellation cutoff. A cancellation does not automatically refund a settled payment." + } + } + } + }, + "Your makerspace checks the cancellation cutoff. This does not automatically refund a payment.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your makerspace checks the cancellation cutoff. This does not automatically refund a payment." + } + } + } + }, + "Your makerspace hasn't provided a privacy policy link.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your makerspace hasn't provided a privacy policy link." + } + } + } + }, + "Your makerspace will review your request and confirm collection and due dates.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your makerspace will review your request and confirm collection and due dates." + } + } + } + }, + "Your makerspace, at a glance.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your makerspace, at a glance." + } + } + } + }, + "Your makerspace, in your pocket.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your makerspace, in your pocket." + } + } + } + }, + "Your makerspaces": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your makerspaces" + } + } + } + }, + "Your membership": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your membership" + } + } + } + }, + "Your membership card isn't active. Ask the front desk for help.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your membership card isn't active. Ask the front desk for help." + } + } + } + }, + "Your new passwords don't match.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your new passwords don't match." + } + } + } + }, + "Your password has been updated. You can now sign in.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your password has been updated. You can now sign in." + } + } + } + }, + "Your registration was cancelled. Cancelling doesn't automatically issue a refund.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your registration was cancelled. Cancelling doesn't automatically issue a refund." + } + } + } + }, + "Your request is empty.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your request is empty." + } + } + } + }, + "Your saved profile will stay as it was.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your saved profile will stay as it was." + } + } + } + }, + "Your security settings changed. Sign in again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your security settings changed. Sign in again." + } + } + } + }, + "Your sign-in expired. Try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your sign-in expired. Try again." + } + } + } + }, + "iOS": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "iOS" + } + } + } + }, + "or with your email": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "or with your email" + } + } + } + } + }, + "version": "1.0" +} diff --git a/SpaceWorks/Resources/en.lproj/InfoPlist.strings b/SpaceWorks/Resources/en.lproj/InfoPlist.strings new file mode 100644 index 0000000..f3549fb --- /dev/null +++ b/SpaceWorks/Resources/en.lproj/InfoPlist.strings @@ -0,0 +1 @@ +"NSCameraUsageDescription" = "Scan member and event codes or photograph handover evidence when you choose to."; diff --git a/SpaceWorks/RootView.swift b/SpaceWorks/RootView.swift index f944dbc..fc24912 100644 --- a/SpaceWorks/RootView.swift +++ b/SpaceWorks/RootView.swift @@ -169,6 +169,7 @@ private struct MemberWorkspace: View { @State private var drafts: MemberActionDrafts @State private var capabilities: MemberCapabilities @State private var payments: PaymentsState + @State private var account: AccountState? let actions: LiveMemberActionsRepository @State private var columns = NavigationSplitViewVisibility.automatic init(state: AppState, auth: AuthState, origin: ServerOrigin, id: Int, slug: String, linkRouter: MobileLinkRouter) { @@ -177,6 +178,7 @@ private struct MemberWorkspace: View { let actions = LiveMemberActionsRepository(service: auth.service, expectedCredentials: auth.acceptedCredentials, id: id, slug: slug, onUnauthorized: failureHandlers.unauthorized, onForbidden: failureHandlers.forbidden) self.actions = actions + _account = State(initialValue: auth.accountState(makerspaceID: id)) _payments = State(initialValue: PaymentsState(repository: LivePaymentRepository(service: auth.service, expectedCredentials: auth.acceptedCredentials, makerspaceID: id, origin: origin, onUnauthorized: failureHandlers.unauthorized, onForbidden: failureHandlers.forbidden))) _cart = State(initialValue: RequestCartState(repository: actions)) @@ -196,7 +198,7 @@ private struct MemberWorkspace: View { navigation.selected = tab columns = geometry.size.width >= 1000 ? .all : .detailOnly } label: { - Text(tab.rawValue).frame(maxWidth: .infinity, minHeight: 48, alignment: .leading) + Text(LocalizedStringKey(tab.rawValue)).frame(maxWidth: .infinity, minHeight: 48, alignment: .leading) }.buttonStyle(.plain).accessibilityIdentifier("nav-\(tab.rawValue)") .foregroundStyle(navigation.selected == tab ? Color.swOnAccent : Color.swInk) .listRowBackground(navigation.selected == tab ? Color.swSidebarActive : Color.swSidebar) @@ -208,7 +210,7 @@ private struct MemberWorkspace: View { TabView(selection: $navigation.selected) { ForEach(AppTab.allCases) { tab in destination(tab) - .tabItem { Label(tab.rawValue, systemImage: tab.symbol) }.tag(tab) + .tabItem { Label(LocalizedStringKey(tab.rawValue), systemImage: tab.symbol) }.tag(tab) .badge(tab == .inbox ? member.unreadCount ?? 0 : 0) } } @@ -219,14 +221,16 @@ private struct MemberWorkspace: View { columns = width >= 1000 ? .all : .detailOnly } }.task { - auth.registerPrivateHolder(privateHolderOwner) { [member, cart, drafts, capabilities, navigation, payments] in - member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate() + auth.registerPrivateHolder(privateHolderOwner) { [member, cart, drafts, capabilities, navigation, payments, account] in + member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); account?.invalidate() } async let config: Void = capabilities.load(); await member.loadUnreadCount(); await config }.onDisappear { auth.unregisterPrivateHolder(privateHolderOwner) - member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate() + member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); account?.invalidate() } + .onChange(of: capabilities.selfService, initial: true) { _, _ in account?.updateCapabilities(selfService: capabilities.selfService, exports: capabilities.exports) } + .onChange(of: capabilities.exports, initial: true) { _, _ in account?.updateCapabilities(selfService: capabilities.selfService, exports: capabilities.exports) } .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await capabilities.load(); await member.loadHome(); await member.loadUnreadCount() } } } @@ -245,9 +249,9 @@ private struct MemberWorkspace: View { } private func destination(_ tab: AppTab) -> some View { NavigationStack(path: navigation.path(tab)) { - MemberScreen(state: state, auth: auth, member: member, tab: tab, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, payments: payments, linkRouter: linkRouter) + MemberScreen(state: state, auth: auth, member: member, tab: tab, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, payments: payments, account: account, linkRouter: linkRouter) .navigationDestination(for: MemberRoute.self) { route in - MemberRouteView(route: route, member: member, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, isDemo: false, payments: payments) { await auth.foreground() } + MemberRouteView(route: route, member: member, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, isDemo: false, payments: payments, account: account) { await auth.foreground() } } } } @@ -265,6 +269,7 @@ private struct MemberScreen: View { let navigation: MemberNavigation let capabilities: MemberCapabilities let payments: PaymentsState + let account: AccountState? let linkRouter: MobileLinkRouter @State private var accountForm: AccountAccessState? @State private var replayIntro = false @@ -290,6 +295,7 @@ private struct MemberScreen: View { if let error = capabilities.error { SWError(message: error); Button("Retry account options") { Task { await capabilities.load() } }.frame(minHeight: 48) } if capabilities.selfService { NavigationLink("Edit profile", value: MemberRoute.contact).frame(minHeight: 48) } if capabilities.preferences { NavigationLink("Communication preferences", value: MemberRoute.preferences).frame(minHeight: 48) } + if account != nil { NavigationLink("Account and security", value: MemberRoute.account).frame(minHeight: 48).accessibilityIdentifier("account-entry") } NavigationLink("Payments", value: MemberRoute.payments).frame(minHeight: 48).accessibilityIdentifier("payments-entry") if let notifications = auth.notifications { NavigationLink("Notifications") { NotificationSettingsView(state: notifications) }.frame(minHeight: 48).accessibilityIdentifier("notifications-entry") @@ -300,11 +306,11 @@ private struct MemberScreen: View { } }.padding(24).frame(maxWidth: 960, alignment: .leading).frame(maxWidth: .infinity) } } - }.background(Color.swBg).navigationTitle(tab.rawValue) + }.background(Color.swBg).navigationTitle(LocalizedStringKey(tab.rawValue)) .sheet(item: $accountForm) { form in AccountAccessView(form: form) { accountForm = nil } } .sheet(isPresented: $replayIntro) { IntroductionView { replayIntro = false } } .confirmationDialog("Sign out?", isPresented: $confirmSignOut, titleVisibility: .visible) { - Button("Sign out", role: .destructive) { member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); Task { await auth.signOut() } } + Button("Sign out", role: .destructive) { member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); account?.invalidate(); Task { await auth.signOut() } } Button("Cancel", role: .cancel) {} } message: { Text("You'll need to sign in again to use this account.") } .toolbar { @@ -314,11 +320,11 @@ private struct MemberScreen: View { Button(membership.slug) { guard auth.selectedID != membership.id else { return } linkRouter.cancelIntent() - member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate() + member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); account?.invalidate() Task { await auth.select(membership.id) } } } - } label: { Label(auth.selectedMembership?.slug ?? "Choose a makerspace", systemImage: "building.2") } + } label: { Label(auth.selectedMembership?.slug ?? String(localized: "Choose a makerspace"), systemImage: "building.2") } .accessibilityIdentifier("makerspace-switcher") } } diff --git a/SpaceWorks/SpaceWorksApp.swift b/SpaceWorks/SpaceWorksApp.swift index 28419e6..6315ce5 100644 --- a/SpaceWorks/SpaceWorksApp.swift +++ b/SpaceWorks/SpaceWorksApp.swift @@ -75,9 +75,10 @@ final class PreviewHealthProtocol: URLProtocol { case "/api/v1/bootstrap": body = DebugMemberFixtures.bootstrap case "/api/v1/auth/device/attestation-challenge": body = #"{"challenge":"fresh-test-challenge","expires_in":120}"# case "/api/v1/auth/device/login": body = arguments.contains("--mfa") ? DebugAuthFixtures.mfa : DebugAuthFixtures.tokens - case "/api/v1/auth/me": body = DebugAuthFixtures.user + case "/api/v1/auth/me": body = arguments.contains("--temporary-password") ? DebugAuthFixtures.user.replacingOccurrences(of: "\"must_change_password\":false", with: "\"must_change_password\":true") : DebugAuthFixtures.user case "/api/v1/auth/mfa/verify": body = "{\"access\":\"test-access\",\"refresh\":\"test-refresh\",\"device_grant\":\(DebugAuthFixtures.grant),\"surface\":\"member\",\"user\":\(DebugAuthFixtures.user)}" case "/api/v1/auth/device/logout": body = #"{"detail":"Signed out."}"# + case "/api/v1/auth/change-password": body = #"{"detail":"Password updated."}"# case "/api/v1/auth/member-sign-up", "/api/v1/auth/forgot-password", "/api/v1/auth/reset-password", "/api/v1/auth/email-verification/confirm", "/api/v1/auth/email-verification/resend": body = #"{"detail":"Check your email."}"# default: body = #"{"status":"ok"}"# diff --git a/SpaceWorks/StaffCheckInState.swift b/SpaceWorks/StaffCheckInState.swift index 1368a52..96e10b3 100644 --- a/SpaceWorks/StaffCheckInState.swift +++ b/SpaceWorks/StaffCheckInState.swift @@ -24,14 +24,14 @@ final class StaffCheckInState { guard active, parent.canRequest, parent.can("manage_events"), !busy, eventMakerspaceID == parent.makerspaceID else { return } generation += 1; let current = generation; person = nil; confirmation = nil; error = nil let value = token.trimmingCharacters(in: .whitespacesAndNewlines) - guard !value.isEmpty, value.utf8.count <= 2048, !value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { error = "Enter a valid check-in code."; return } + guard !value.isEmpty, value.utf8.count <= 2048, !value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { error = String(localized: "Enter a valid check-in code."); return } busy = true do { let resolved = try await parent.repository.resolveCheckIn(eventID: eventID, body: .init(checkin_token: value)) guard active, current == generation else { return }; person = resolved; busy = false } catch { guard active, current == generation else { return }; busy = false; parent.refuse(error) - self.error = APIError.status(error) == 404 ? "That is not a check-in code for this event." : APIError.display(error) + self.error = APIError.status(error) == 404 ? String(localized: "That is not a check-in code for this event.") : APIError.display(error) } } /// Called only by the separate visible confirmation button, never by the camera callback. @@ -41,10 +41,10 @@ final class StaffCheckInState { do { _ = try await parent.repository.markAttended(registrationID: person.registration_id, body: .init(source: .init(value1: .qr))) guard active, current == generation else { return } - confirmation = "Attendance recorded for \(person.name)."; self.person = nil; busy = false + confirmation = String(localized: "Attendance recorded for \(person.name)."); self.person = nil; busy = false } catch { guard active, current == generation else { return }; self.person = nil; busy = false; parent.refuse(error) - self.error = APIError.status(error) == 409 ? "This registration changed. Scan the code again to check its latest status." : APIError.display(error) + self.error = APIError.status(error) == 409 ? String(localized: "This registration changed. Scan the code again to check its latest status.") : APIError.display(error) } } } diff --git a/SpaceWorks/StaffDemo.swift b/SpaceWorks/StaffDemo.swift index 5c5c084..1d4e74d 100644 --- a/SpaceWorks/StaffDemo.swift +++ b/SpaceWorks/StaffDemo.swift @@ -49,7 +49,7 @@ actor DemoStaffRepository: StaffRepository { return try self.page(rows, page: page) } private func changed() -> APIError { - APIError(status: 409, code: "changed", detail: "This changed. Refresh and try again.") + APIError(status: 409, code: "changed", detail: String(localized: "This changed. Refresh and try again.")) } private func request(_ id: Int) throws -> Components.Schemas.AdminRequest { try checked() @@ -92,7 +92,7 @@ actor DemoStaffRepository: StaffRepository { } func issue(id: Int, body: Components.Schemas.IssueRequest) throws -> Components.Schemas.AdminRequest { var row = try request(id) - if scenario == .issueConflict { throw APIError(status: 409, code: "issue_conflict", detail: "The request changed before issue completed.") } + if scenario == .issueConflict { throw APIError(status: 409, code: "issue_conflict", detail: String(localized: "The request changed before issue completed.")) } guard row.status == "accepted", assignments.contains(id), body.evidence_id > 0, row.items.contains(where: { $0.accepted_quantity > 0 }) else { throw changed() } row.items = row.items.map { initial in var item = initial; item.issued_quantity = item.accepted_quantity; return item } @@ -127,7 +127,7 @@ actor DemoStaffRepository: StaffRepository { return save(row) } func resolveQR(body: Components.Schemas.QrResolve) throws -> Components.Schemas.QrResolveResult { - guard ["DEMO-BOX", "DEMO-TOOL"].contains(body.payload) else { throw APIError(status: 404, code: "not_found", detail: "Use DEMO-BOX or DEMO-TOOL in this sample.") } + guard ["DEMO-BOX", "DEMO-TOOL"].contains(body.payload) else { throw APIError(status: 404, code: "not_found", detail: String(localized: "Use DEMO-BOX or DEMO-TOOL in this sample.")) } var object = try JSONSerialization.jsonObject(with: Data(DebugStaffFixtures.qr.utf8)) as! [String: Any] var scopedQR = object["qr"] as! [String: Any]; scopedQR["makerspace"] = makerspaceID; object["qr"] = scopedQR if body.payload == "DEMO-TOOL" { @@ -192,7 +192,7 @@ actor DemoStaffRepository: StaffRepository { func evidence(body: Components.Schemas.EvidenceUrlRequest) async throws -> Components.Schemas.EvidenceUrlResponse { await beforeEvidence() try checked() - if scenario == .uploadRefused { throw APIError(status: 403, code: "upload_refused", detail: "The sample evidence upload was refused.") } + if scenario == .uploadRefused { throw APIError(status: 403, code: "upload_refused", detail: String(localized: "The sample evidence upload was refused.")) } evidenceCounter += 1 return try decode(#"{"evidence_id":0,"upload_url":"https://storage.example.invalid/demo-only","object_key":"demo-only","fields":{}}"#, changes: ["evidence_id": evidenceCounter]) } @@ -203,14 +203,14 @@ actor DemoStaffRepository: StaffRepository { return try self.page([row], page: page, extra: ["default_registration_audience": "members"]) } func resolveCheckIn(eventID: Int, body: Components.Schemas.EventCheckInResolveRequest) throws -> Components.Schemas.EventCheckInResolveResponse { - guard eventID == 801, body.checkin_token == "DEMO-EVENT" else { throw APIError(status: 404, code: "not_found", detail: "That is not a check-in code for this event.") } + guard eventID == 801, body.checkin_token == "DEMO-EVENT" else { throw APIError(status: 404, code: "not_found", detail: String(localized: "That is not a check-in code for this event.")) } let status = attended ? "attended" : scenario == .waitlisted ? "waitlisted" : "registered" return try decode(DebugStaffFixtures.checkin, changes: ["status": status, "confirmable": status == "registered"]) } func markAttended(registrationID: Int, body: Components.Schemas.EventAttendanceMark) throws -> Components.Schemas.EventRegistrationAdmin { try checked() - if scenario == .attendanceConflict { throw APIError(status: 409, code: "changed", detail: "This registration changed.") } - guard registrationID == 1101, !attended, scenario != .waitlisted else { throw APIError(status: 409, code: "changed", detail: "This registration changed.") } + if scenario == .attendanceConflict { throw APIError(status: 409, code: "changed", detail: String(localized: "This registration changed.")) } + guard registrationID == 1101, !attended, scenario != .waitlisted else { throw APIError(status: 409, code: "changed", detail: String(localized: "This registration changed.")) } attended = true; return try decode(DebugStaffFixtures.registration, changes: ["status": "attended"]) } func spaces(page: Int) throws -> Components.Schemas.BookableSpaceListResponse { @@ -223,7 +223,7 @@ actor DemoStaffRepository: StaffRepository { } func approveBooking(id: Int) throws -> Components.Schemas.BookingAdmin { try checked(); bookingStatus = "confirmed" - if scenario == .bookingConflict { throw APIError(status: 409, code: "changed", detail: "This booking changed. Refresh its status.") } + if scenario == .bookingConflict { throw APIError(status: 409, code: "changed", detail: String(localized: "This booking changed. Refresh its status.")) } return try decode(DebugStaffFixtures.booking, changes: ["status": bookingStatus]) } func rejectBooking(id: Int) throws -> Components.Schemas.BookingAdmin { try checked(); bookingStatus = "rejected"; return try decode(DebugStaffFixtures.booking, changes: ["status": bookingStatus]) } diff --git a/SpaceWorks/StaffHandoverState.swift b/SpaceWorks/StaffHandoverState.swift index 277086c..b200e0e 100644 --- a/SpaceWorks/StaffHandoverState.swift +++ b/SpaceWorks/StaffHandoverState.swift @@ -27,7 +27,7 @@ enum StaffHandoverMode { var request: Components.Schemas.AdminRequest? { switch self { case .issue(let row), .returnRequest(let row): return row; default: return nil } } var loan: Components.Schemas.DirectLoan? { if case .directReturn(let row) = self { return row }; return nil } var isDirect: Bool { switch self { case .directIssue, .directReturn: return true; default: return false } } - var title: String { switch self { case .issue: return "Issue equipment"; case .returnRequest, .directReturn: return "Return equipment"; case .directIssue: return "Direct handout" } } + var title: String { switch self { case .issue: return String(localized: "Issue equipment"); case .returnRequest, .directReturn: return String(localized: "Return equipment"); case .directIssue: return String(localized: "Direct handout") } } } /// Editable return counts are UI state. Only generated ReturnItemResolution is sent to the API. @@ -101,7 +101,7 @@ final class StaffHandoverState { guard let data = try? JSONEncoder().encode(value), let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], let id = StaffMetadata.positiveID(object["asset_id"]) else { return nil } - return (id, object["asset_tag"] as? String ?? "Asset #\(id)") + return (id, object["asset_tag"] as? String ?? String(localized: "Asset #\(id)")) } let remaining = StaffMetadata.remaining(issued: item.issued_quantity, returned: item.returned_quantity, damaged: item.damaged_quantity, missing: item.missing_quantity) let metadataValid = remaining != nil && (!item.requires_asset_qr || remaining == 0 || (!assets.isEmpty && assets.count == item.issued_assets.count && Set(assets.map(\.id)).count == assets.count)) @@ -183,7 +183,7 @@ final class StaffHandoverState { func scan(_ raw: String, box: Bool) async { guard canRequest, !assignmentRecorded else { return } let value = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !value.isEmpty, value.utf8.count <= 2048, !value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { error = "Enter a valid QR payload."; return } + guard !value.isEmpty, value.utf8.count <= 2048, !value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { error = String(localized: "Enter a valid QR payload."); return } busy = true; error = nil do { let result = try await parent.repository.resolveQR(body: .init(payload: value)); guard active else { return } @@ -198,7 +198,7 @@ final class StaffHandoverState { if type == "box" { guard result.qr.target_id == loan.container_id else { throw validation("Scan the container for this loan.") } } if type == "asset" { guard loan.return_items.flatMap(\.assets).contains(where: { $0.asset_id == result.qr.target_id }) else { throw validation("Scan an asset from this loan.") } } } else { guard type == "box" else { throw validation("Scan the container for this handover.") } } - boxCode = canonical; boxLabel = result.target.additionalProperties["label"]?.value as? String ?? "Container #\(result.qr.target_id)" + boxCode = canonical; boxLabel = result.target.additionalProperties["label"]?.value as? String ?? String(localized: "Container #\(result.qr.target_id)") } else { guard type != "box" else { throw validation("Scan an equipment or asset code here.") } if mode.request != nil { @@ -234,7 +234,7 @@ final class StaffHandoverState { guard active, !busy, receipt == nil, !needsReload else { return } generation += 1; evidenceID = nil; error = nil photo = data.count <= 5 * 1024 * 1024 && data.starts(with: [0xff, 0xd8, 0xff]) ? data : nil - if photo == nil { error = "Choose a JPEG photo up to 5 MB." } + if photo == nil { error = String(localized: "Choose a JPEG photo up to 5 MB.") } } func uploadPhoto() async { guard canRequest, let photo else { return }; busy = true; evidenceID = nil; error = nil @@ -251,7 +251,7 @@ final class StaffHandoverState { func confirm() async { guard ready, let evidenceID else { return } let permission = mode.isReturn ? "return_request" : (mode.isDirect ? "issue_direct_loan" : "issue_request") - guard parent.can(permission), parent.can("upload_evidence"), mode.isDirect || mode.isReturn || parent.can("assign_box") else { error = "You no longer have permission for this handover."; return } + guard parent.can(permission), parent.can("upload_evidence"), mode.isDirect || mode.isReturn || parent.can("assign_box") else { error = String(localized: "You no longer have permission for this handover."); return } busy = true; error = nil do { let id: Int @@ -276,11 +276,11 @@ final class StaffHandoverState { notes: remark.trimmingCharacters(in: .whitespacesAndNewlines), qr_payload: boxCode.isEmpty ? nil : boxCode, resolutions: returns.map(\.body))).id } guard active else { return }; receipt = "\(mode.isReturn ? "Return" : "Issue") #\(id) recorded" - notice = "Evidence photo #\(evidenceID) is attached."; busy = false; step = steps.count - 1 + notice = String(localized: "Evidence photo #\(evidenceID) is attached."); busy = false; step = steps.count - 1 await parent.loadQueue(); await parent.loadDirect() } catch { guard active else { return }; needsReload = true; fail(error) - if assignmentRecorded { notice = "Box assignment was recorded; issue still needs to complete. Check the latest request before retrying." } + if assignmentRecorded { notice = String(localized: "Box assignment was recorded; issue still needs to complete. Check the latest request before retrying.") } } } private func validation(_ detail: String) -> APIError { APIError(status: 0, code: "handover_validation", detail: detail) } diff --git a/SpaceWorks/StaffHandoverView.swift b/SpaceWorks/StaffHandoverView.swift index 2dc4cdb..b6ddb65 100644 --- a/SpaceWorks/StaffHandoverView.swift +++ b/SpaceWorks/StaffHandoverView.swift @@ -23,7 +23,7 @@ struct StaffHandoverView: View { if let notice = state.notice { Label(notice, systemImage: "info.circle").foregroundStyle(Color.swMuted) } if state.busy { ProgressView("Working…") } if !state.parent.isActive { - SWError(message: state.parent.error ?? "Your access changed. Return to your makerspace before continuing.") + SWError(message: state.parent.error ?? String(localized: "Your access changed. Return to your makerspace before continuing.")) Button("Close") { dismiss() }.buttonStyle(SWPrimary()) } else if let receipt = state.receipt { SWCard { @@ -58,14 +58,14 @@ struct StaffHandoverView: View { if directIssue { await state.loadBorrowers(); await state.loadProducts() } } .sheet(item: $scanner) { kind in - NativeQRScanner(title: kind == .box ? "Check handover code" : "Add equipment") { value in + NativeQRScanner(title: kind == .box ? String(localized: "Check handover code") : String(localized: "Add equipment")) { value in scanner = nil; Task { await state.scan(value, box: kind == .box) } } } .fullScreenCover(isPresented: $camera) { NativeEvidenceCamera(onPhoto: { result in camera = false - switch result { case .success(let photo): state.setPhoto(photo); case .failure: cameraMessage = "This photo could not be prepared. Take another photo." } + switch result { case .success(let photo): state.setPhoto(photo); case .failure: cameraMessage = String(localized: "This photo could not be prepared. Take another photo.") } }, onCancel: { camera = false }).ignoresSafeArea() } .confirmationDialog(state.mode.isReturn ? "Record this return?" : "Record this handout?", isPresented: $confirm, titleVisibility: .visible) { @@ -133,10 +133,10 @@ struct StaffHandoverView: View { } Button("Scan equipment code") { scanner = .equipment }.frame(minHeight: 48).disabled(state.busy) Text("\(state.scans.count) equipment codes checked").font(.subheadline).foregroundStyle(Color.swMuted) - if !state.duplicateProducts.isEmpty { SWError(message: "This equipment is both scanned and selected by quantity. Remove one selection before continuing.") } + if !state.duplicateProducts.isEmpty { SWError(message: String(localized: "This equipment is both scanned and selected by quantity. Remove one selection before continuing.")) } ForEach(state.scans.keys.sorted(), id: \.self) { payload in HStack(alignment: .top) { - Text(state.scans[payload]?.target.additionalProperties["label"]?.value as? String ?? "Equipment code added").frame(maxWidth: .infinity, alignment: .leading) + Text(state.scans[payload]?.target.additionalProperties["label"]?.value as? String ?? String(localized: "Equipment code added")).frame(maxWidth: .infinity, alignment: .leading) Button("Remove") { state.removeScan(payload) }.frame(minHeight: 48).accessibilityLabel("Remove \(state.scans[payload]?.target.additionalProperties["label"]?.value as? String ?? "equipment code")") } } @@ -147,7 +147,7 @@ struct StaffHandoverView: View { SWCard { Text(state.returns[index].label).font(.headline) Text("Remaining: \(state.returns[index].remaining)").foregroundStyle(Color.swMuted) - if !state.returns[index].metadataValid { SWError(message: "Asset details are unavailable. Refresh this loan before recording a return.") } + if !state.returns[index].metadataValid { SWError(message: String(localized: "Asset details are unavailable. Refresh this loan before recording a return.")) } else if state.returns[index].assets.isEmpty { countStepper("Returned", index: index, keyPath: \.returned) countStepper("Damaged", index: index, keyPath: \.damaged) @@ -204,11 +204,11 @@ struct StaffHandoverView: View { do { switch try await NativeCameraAccess.request() { case .ready: cameraMessage = nil; camera = true - case .denied: cameraMessage = "Allow Camera in Settings to take an evidence photo." - case .restricted: cameraMessage = "Camera access is restricted on this device. Ask an administrator or use another device." - case .unavailable: cameraMessage = "This device has no available camera. Use a device with a camera to record this handover." + case .denied: cameraMessage = String(localized: "Allow Camera in Settings to take an evidence photo.") + case .restricted: cameraMessage = String(localized: "Camera access is restricted on this device. Ask an administrator or use another device.") + case .unavailable: cameraMessage = String(localized: "This device has no available camera. Use a device with a camera to record this handover.") } - } catch { cameraMessage = "Camera request cancelled." } + } catch { cameraMessage = String(localized: "Camera request cancelled.") } } #if DEBUG private func samplePhoto() { diff --git a/SpaceWorks/StaffRepository.swift b/SpaceWorks/StaffRepository.swift index 5fcf410..39389b8 100644 --- a/SpaceWorks/StaffRepository.swift +++ b/SpaceWorks/StaffRepository.swift @@ -66,7 +66,7 @@ struct LiveStaffRepository: StaffRepository { parts.percentEncodedPath == path, let items = parts.queryItems, items.count == 1, items[0].name == "page", let raw = items[0].value, !raw.isEmpty, raw.allSatisfy({ $0.isASCII && $0.isNumber }), let number = Int(raw), number > page else { - throw APIError(status: 0, code: "invalid_next_page", detail: "The next staff page is unavailable. Refresh and try again.") + throw APIError(status: 0, code: "invalid_next_page", detail: String(localized: "The next staff page is unavailable. Refresh and try again.")) } } func requests(queue: StaffQueue, page: Int) async throws -> Components.Schemas.PaginatedAdminRequestList { @@ -105,12 +105,12 @@ struct LiveStaffRepository: StaffRepository { private func validateInventory(_ next: String?, page: Int, search: String) throws { guard let next else { return } guard var parts = URLComponents(string: next) else { - throw APIError(status: 0, code: "invalid_next_page", detail: "The next staff page is unavailable. Refresh and try again.") + throw APIError(status: 0, code: "invalid_next_page", detail: String(localized: "The next staff page is unavailable. Refresh and try again.")) } let query = parts.queryItems ?? [] guard query.filter({ $0.name == "q" }).count <= 1, query.allSatisfy({ $0.name == "page" || ($0.name == "q" && $0.value == search) }) else { - throw APIError(status: 0, code: "invalid_next_page", detail: "The next staff page is unavailable. Refresh and try again.") + throw APIError(status: 0, code: "invalid_next_page", detail: String(localized: "The next staff page is unavailable. Refresh and try again.")) } parts.queryItems = query.filter { $0.name == "page" } try validate(parts.string, path: "/api/v1/admin/makerspace/\(makerspaceID)/inventory", after: page) diff --git a/SpaceWorks/StaffState.swift b/SpaceWorks/StaffState.swift index d6f32de..7f2a41e 100644 --- a/SpaceWorks/StaffState.swift +++ b/SpaceWorks/StaffState.swift @@ -70,13 +70,13 @@ final class StaffState { func accept(_ row: Components.Schemas.AdminRequest, quantities: [Int: Int]) async { guard can("accept_request"), row.makerspace_id == makerspaceID, row.items.allSatisfy({ $0.requested_quantity >= 0 && (0...$0.requested_quantity).contains(quantities[$0.id] ?? 0) }), - quantities.values.contains(where: { $0 > 0 }) else { error = "Choose a valid quantity for at least one item."; return } + quantities.values.contains(where: { $0 > 0 }) else { error = String(localized: "Choose a valid quantity for at least one item."); return } let body = Components.Schemas.AcceptRequest(accepted_quantities: row.items.map { .init(item_id: $0.id, quantity: quantities[$0.id] ?? 0) }) await mutate("Request accepted.") { _ = try await self.repository.accept(id: row.id, body: body) } } func reject(_ row: Components.Schemas.AdminRequest, reason: String) async { let value = reason.trimmingCharacters(in: .whitespacesAndNewlines) - guard can("reject_request"), row.makerspace_id == makerspaceID, !value.isEmpty else { error = "Add a reason before rejecting this request."; return } + guard can("reject_request"), row.makerspace_id == makerspaceID, !value.isEmpty else { error = String(localized: "Add a reason before rejecting this request."); return } await mutate("Request rejected.") { _ = try await self.repository.reject(id: row.id, body: .init(reason: value)) } } private func mutate(_ success: String, operation: () async throws -> Void) async { @@ -134,7 +134,7 @@ final class StaffState { busy = false guard active, scopeGeneration == bookingsGeneration, selectedSpace == row.space_id else { return } bookings = bookings.map { $0.id == updated.id ? updated : $0 } - notice = approve ? "Booking approved." : "Booking rejected."; busy = false + notice = approve ? String(localized: "Booking approved.") : String(localized: "Booking rejected."); busy = false } catch { guard active else { return }; refuse(error); self.error = APIError.display(error); busy = false if active, scopeGeneration == bookingsGeneration, selectedSpace == row.space_id { await loadBookings(spaceID: row.space_id) } @@ -152,7 +152,7 @@ final class StaffState { } if [401, 403].contains(APIError.status(error)) { invalidate(); self.error = APIError.display(error) - notice = "Your access changed. Return to your makerspace before continuing." + notice = String(localized: "Your access changed. Return to your makerspace before continuing.") } } } @@ -164,7 +164,7 @@ enum StaffPaging { items.filter({ $0.name == "page" }).count == 1, let value = items.first(where: { $0.name == "page" })?.value, let next = Int(value), next > page else { - throw APIError(status: 0, code: "invalid_next_page", detail: "The next page is unavailable. Refresh and try again.") + throw APIError(status: 0, code: "invalid_next_page", detail: String(localized: "The next page is unavailable. Refresh and try again.")) } return next } diff --git a/SpaceWorks/StaffViews.swift b/SpaceWorks/StaffViews.swift index 9d7ea7a..656df48 100644 --- a/SpaceWorks/StaffViews.swift +++ b/SpaceWorks/StaffViews.swift @@ -30,7 +30,7 @@ struct StaffView: View { } private struct StaffMenuRow: View { - let title: String; let detail: String; let symbol: String + let title: LocalizedStringKey; let detail: LocalizedStringKey; let symbol: String var body: some View { HStack(alignment: .top, spacing: 16) { Image(systemName: symbol).font(.title2).accessibilityHidden(true) @@ -56,7 +56,7 @@ struct StaffRequestsView: View { NavigationLink { StaffRequestDetail(state: state, row: row) } label: { SWCard { Text(row.requester_display.isEmpty ? row.requester_username : row.requester_display).font(.headline).foregroundStyle(Color.swInk) - Text("Request #\(row.id) · \(row.status.replacingOccurrences(of: "_", with: " ").capitalized)").foregroundStyle(Color.swMuted) + Text("Request #\(row.id) · \(NativeStatusCopy.label(row.status))").foregroundStyle(Color.swMuted) ForEach(row.items, id: \.id) { item in Text("\(item.product_name) × \(state.queue == .pending ? item.requested_quantity : item.accepted_quantity)").foregroundStyle(Color.swInk) } Label("Review details", systemImage: "chevron.right").font(.subheadline) } @@ -80,7 +80,7 @@ private struct StaffRequestDetail: View { ScrollView { VStack(alignment: .leading, spacing: 24) { Text(row.requester_display.isEmpty ? row.requester_username : row.requester_display).font(.largeTitle.bold()) - Text("Request #\(row.id) · \(row.status.replacingOccurrences(of: "_", with: " ").capitalized)").foregroundStyle(Color.swMuted) + Text("Request #\(row.id) · \(NativeStatusCopy.label(row.status))").foregroundStyle(Color.swMuted) ForEach(row.items, id: \.id) { item in SWCard { Text(item.product_name).font(.headline) @@ -125,7 +125,7 @@ private struct StaffDirectLoansView: View { StaffFeedback(load: state.directLoad) { await state.loadDirect() } ForEach(state.directLoans, id: \.id) { row in SWCard { - Text(row.target_label).font(.headline); Text("Loan #\(row.id) · \(row.status.capitalized)").foregroundStyle(Color.swMuted) + Text(row.target_label).font(.headline); Text("Loan #\(row.id) · \(NativeStatusCopy.label(row.status))").foregroundStyle(Color.swMuted) if let due = row.due_at { Text("Due \(due.formatted(date: .abbreviated, time: .shortened))") } if row.status == "active" && state.canReturn { NavigationLink("Record return") { StaffHandoverView(state: StaffHandoverState(parent: state, mode: .directReturn(row))) }.frame(minHeight: 48) @@ -162,7 +162,7 @@ private struct StaffCheckInView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { if !state.parent.isActive { - SWError(message: state.parent.error ?? "Your access changed. Return to your makerspace before continuing.") + SWError(message: state.parent.error ?? String(localized: "Your access changed. Return to your makerspace before continuing.")) Button("Close") { dismiss() }.buttonStyle(SWPrimary()) } else { Text(state.eventTitle).font(.largeTitle.bold()) @@ -173,15 +173,15 @@ private struct StaffCheckInView: View { if let confirmation = state.confirmation { Label(confirmation, systemImage: "checkmark.circle").font(.headline).accessibilityIdentifier("staff-attendance-recorded") } if let person = state.person { SWCard { - Text(person.name).font(.title2.bold()); Text(person.status.replacingOccurrences(of: "_", with: " ").capitalized) - Text("Payment: \((person.payment_status ?? "Unavailable").replacingOccurrences(of: "_", with: " "))").foregroundStyle(Color.swMuted) + Text(person.name).font(.title2.bold()); Text(NativeStatusCopy.label(person.status)) + Text("Payment: \((person.payment_status ?? String(localized: "Unavailable")).replacingOccurrences(of: "_", with: " "))").foregroundStyle(Color.swMuted) if state.canConfirm { Button("Confirm attendance") { Task { await state.confirm() } }.buttonStyle(SWPrimary()).accessibilityIdentifier("staff-confirm-attendance") } else { Text("This registration cannot be checked in. Scan another code or ask the event manager.").foregroundStyle(Color.swMuted) } } } } }.padding(24).frame(maxWidth: 720).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle("Check-in").navigationBarTitleDisplayMode(.inline) - .sheet(isPresented: $scanner) { NativeQRScanner(title: "Check-in code") { value in scanner = false; Task { await state.resolve(value) } } } + .sheet(isPresented: $scanner) { NativeQRScanner(title: String(localized: "Check-in code")) { value in scanner = false; Task { await state.resolve(value) } } } } } @@ -211,7 +211,7 @@ private struct StaffBookingsView: View { ForEach(state.bookings, id: \.id) { row in SWCard { Text(row.name).font(.headline); Text(row.starts_at, format: .dateTime.day().month().hour().minute()) - Text(row.status.value1.rawValue.replacingOccurrences(of: "_", with: " ").capitalized).foregroundStyle(Color.swMuted) + Text(NativeStatusCopy.label(row.status.value1.rawValue)).foregroundStyle(Color.swMuted) if !row.note.isEmpty { Text(row.note) } if row.status.value1.rawValue == "pending" { Button("Approve booking") { decision = (row, true) }.buttonStyle(SWPrimary()).disabled(!state.canRequest) diff --git a/SpaceWorksTests/AccountAuthTests.swift b/SpaceWorksTests/AccountAuthTests.swift new file mode 100644 index 0000000..2c52dda --- /dev/null +++ b/SpaceWorksTests/AccountAuthTests.swift @@ -0,0 +1,65 @@ +#if DEBUG +import Foundation +import XCTest +import SpaceWorksAPI +@testable import SpaceWorks + +@MainActor final class AccountAuthTests: XCTestCase { + override func tearDown() { StubProtocol.lock.lock(); StubProtocol.handler = nil; StubProtocol.lock.unlock() } + private func state(storage: AccountAuthStorage, me: @escaping () throws -> String) throws -> AuthState { + StubProtocol.lock.lock(); StubProtocol.handler = { request in + let data = request.url?.path == "/api/v1/auth/me" ? try me() : DebugAuthFixtures.config + return (200, ["Content-Type": "application/json"], Data(data.utf8)) + }; StubProtocol.lock.unlock() + let configuration = DeviceSession.configuration(); configuration.protocolClasses = [StubProtocol.self] + return AuthState(origin: try ServerOrigin("https://account-auth-" + UUID().uuidString.lowercased() + ".example"), + session: DeviceSession.make(configuration: configuration), settings: UserDefaults(suiteName: UUID().uuidString)!, storage: storage) + } + func testZeroMembershipIdentityKeepsGlobalSecurityAndDevicesWithoutMemberRecords() async throws { + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(DebugAuthFixtures.user.utf8)) as? [String: Any]) + object["makerspaces"] = [] + let identity = String(decoding: try JSONSerialization.data(withJSONObject: object), as: UTF8.self) + let auth = try state(storage: AccountAuthStorage(), me: { identity }); await auth.restore() + XCTAssertEqual(auth.stage, .signedIn); XCTAssertTrue(auth.memberships.isEmpty); XCTAssertNil(auth.selectedID) + let global = try XCTUnwrap(auth.accountState(makerspaceID: nil)) + XCTAssertFalse(global.memberSelfServiceAvailable); XCTAssertFalse(global.dataExportAvailable); XCTAssertTrue(global.isActive) + XCTAssertNil(auth.accountState(makerspaceID: 7, selfService: true, exports: true)) + await auth.invalidateSession(message: "Signed out.") + } + func testConfirmedLeaveClearsProtectedScopeBeforeOfflineRefetchAndRejectsStaleServerEcho() async throws { + let response = AccountIdentityResponse(); let auth = try state(storage: AccountAuthStorage(), me: { try response.read() }) + await auth.restore(); await auth.select(7) + var cleared = false; auth.registerPrivateHolder(UUID()) { cleared = true } + response.offline = true + await auth.confirmedMembershipLeave(7) + XCTAssertTrue(cleared); XCTAssertNil(auth.selectedID); XCTAssertFalse(auth.memberships.contains { $0.id == 7 }) + XCTAssertEqual(Set(auth.memberships.map(\.id)), [8]); XCTAssertNotNil(auth.error) + let selected = await auth.service.makerspace.current(); XCTAssertNil(selected) + response.offline = false + await auth.foreground() + XCTAssertEqual(auth.selectedID, 8); XCTAssertFalse(auth.memberships.contains { $0.id == 7 }); XCTAssertFalse(auth.busy) + await auth.invalidateSession(message: "Signed out.") + } + func testSecurityCodesArePresentedOnlyAfterTokenAndPrivateStateWipeThenExplicitlyCleared() async throws { + let storage = AccountAuthStorage(); let auth = try state(storage: storage, me: { DebugAuthFixtures.user }) + await auth.restore(); await auth.select(7) + var cleared = false; auth.registerPrivateHolder(UUID()) { cleared = true } + await auth.securitySessionEnded(codes: ["demo-recovery-code"], message: "Save your new codes.") + XCTAssertTrue(cleared); XCTAssertNil(storage.load()); XCTAssertNil(auth.user); XCTAssertNil(auth.selectedID) + XCTAssertEqual(auth.stage, .securityCompletion); XCTAssertEqual(auth.recoveryCodes, ["demo-recovery-code"]) + XCTAssertEqual(auth.securityMessage, "Save your new codes.") + auth.finishSecurityCompletion() + XCTAssertEqual(auth.stage, .signIn); XCTAssertTrue(auth.recoveryCodes.isEmpty); XCTAssertNil(auth.securityMessage) + } +} +private final class AccountIdentityResponse: @unchecked Sendable { + private let lock = NSLock(); private var value = false + var offline: Bool { get { lock.lock(); defer { lock.unlock() }; return value } set { lock.lock(); value = newValue; lock.unlock() } } + func read() throws -> String { if offline { throw URLError(.notConnectedToInternet) }; return DebugAuthFixtures.user } +} +private final class AccountAuthStorage: TokenStorage, @unchecked Sendable { + private let lock = NSLock(); private var pair: TokenPair? = .init(access: "account-auth", refresh: "account-refresh") + func load() -> TokenPair? { lock.lock(); defer { lock.unlock() }; return pair } + func store(_ value: TokenPair?) { lock.lock(); defer { lock.unlock() }; pair = value } +} +#endif diff --git a/SpaceWorksTests/AccountCooldownTests.swift b/SpaceWorksTests/AccountCooldownTests.swift new file mode 100644 index 0000000..16557c2 --- /dev/null +++ b/SpaceWorksTests/AccountCooldownTests.swift @@ -0,0 +1,94 @@ +#if DEBUG +import Foundation +import Observation +import SpaceWorksAPI +import XCTest +@testable import SpaceWorks + +@MainActor final class AccountCooldownTests: XCTestCase { + func testCanceledOlderCooldownCannotClearNewerServerDeadline() async throws { + let readsStarted = expectation(description: "Both account reads reached the server") + readsStarted.expectedFulfillmentCount = 2 + let firstSleep = expectation(description: "First cooldown entered its sleep") + let secondSleep = expectation(description: "Replacement cooldown entered its sleep") + let server = AccountCooldownServer(started: readsStarted) + AccountCooldownProtocol.server = server + let sleep = AccountCooldownSleep { index in (index == 0 ? firstSleep : secondSleep).fulfill() } + let configuration = DeviceSession.configuration(); configuration.protocolClasses = [AccountCooldownProtocol.self] + let session = DeviceSession.make(configuration: configuration) + let origin = try ServerOrigin("https://account-cooldown.example") + let service = SpaceWorksService(origin: origin, session: session, initialTokens: .init(access: "sample-access", refresh: "sample-refresh")) + let repository = LiveAccountRepository(service: service, expectedCredentials: await service.refresh.snapshot(), makerspaceID: nil, origin: origin) + let account = AccountState(repository: repository, makerspaceID: nil, makerspaceName: "", cooldownSleep: { try await sleep.wait($0) }) + defer { + account.invalidate(); session.invalidateAndCancel(); AccountCooldownProtocol.server = nil + Task { await sleep.releaseAll() } + } + let security = Task { await account.loadSecurity() } + let providers = Task { await account.loadProviders() } + await fulfillment(of: [readsStarted], timeout: 5) + let securityReply = try XCTUnwrap(server.request("/api/v1/auth/mfa")) + let providersReply = try XCTUnwrap(server.request("/api/v1/auth/social/providers")) + securityReply.reject(retryAfter: 60) + await fulfillment(of: [firstSleep], timeout: 5) + let firstDeadline = account.retryAfter + providersReply.reject(retryAfter: 120) + await fulfillment(of: [secondSleep], timeout: 5) + await security.value; await providers.value + let newerDeadline = account.retryAfter + XCTAssertGreaterThan(newerDeadline, firstDeadline) + XCTAssertFalse(account.canRequest) + + let oldTimerChangedDeadline = expectation(description: "Canceled timer must not change newer deadline") + oldTimerChangedDeadline.isInverted = true + withObservationTracking { _ = account.retryAfter } onChange: { oldTimerChangedDeadline.fulfill() } + // Model a normal sleep completion racing cancellation. The loop then observes cancellation. + await sleep.release(0) + await fulfillment(of: [oldTimerChangedDeadline], timeout: 0.1) + XCTAssertEqual(account.retryAfter, newerDeadline) + XCTAssertFalse(account.canRequest) + account.invalidate(); await sleep.releaseAll() + XCTAssertFalse(account.isActive); XCTAssertFalse(account.canRequest) + } +} + +private actor AccountCooldownSleep { + private var continuations: [Int: CheckedContinuation] = [:] + private var count = 0 + private let started: @Sendable (Int) -> Void + init(started: @escaping @Sendable (Int) -> Void) { self.started = started } + func wait(_ nanoseconds: UInt64) async throws { + XCTAssertGreaterThan(nanoseconds, 0) + let index = count; count += 1 + await withCheckedContinuation { continuations[index] = $0; started(index) } + } + func release(_ index: Int) { continuations.removeValue(forKey: index)?.resume() } + func releaseAll() { let held = continuations.values; continuations = [:]; held.forEach { $0.resume() } } +} + +private final class AccountCooldownServer: @unchecked Sendable { + private let lock = NSLock() + private var requests: [String: AccountCooldownProtocol] = [:] + private let started: XCTestExpectation + init(started: XCTestExpectation) { self.started = started } + func receive(_ request: AccountCooldownProtocol) { + lock.lock(); requests[request.request.url!.path] = request; lock.unlock(); started.fulfill() + } + func request(_ path: String) -> AccountCooldownProtocol? { lock.lock(); defer { lock.unlock() }; return requests[path] } +} + +private final class AccountCooldownProtocol: URLProtocol, @unchecked Sendable { + static var server: AccountCooldownServer? + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { Self.server?.receive(self) } + override func stopLoading() {} + func reject(retryAfter: Int) { + let response = HTTPURLResponse(url: request.url!, statusCode: 429, httpVersion: nil, + headerFields: ["Content-Type": "application/json", "Retry-After": String(retryAfter)])! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(#"{"detail":"Too many requests. Try again later."}"#.utf8)) + client?.urlProtocolDidFinishLoading(self) + } +} +#endif diff --git a/SpaceWorksTests/AccountOperationTests.swift b/SpaceWorksTests/AccountOperationTests.swift new file mode 100644 index 0000000..dddbf04 --- /dev/null +++ b/SpaceWorksTests/AccountOperationTests.swift @@ -0,0 +1,350 @@ +import Foundation +import SpaceWorksAPI +import XCTest +@testable import SpaceWorks + +final class AccountOperationTests: XCTestCase { + private let origin = try! ServerOrigin("https://spaceworks.example") + private let jobID = "84000000-0000-4000-8000-000000000001" + private let grantID = "83000000-0000-4000-8000-000000000001" + override func tearDown() { StubProtocol.lock.lock(); StubProtocol.handler = nil; StubProtocol.lock.unlock() } + private func repository(makerspaceID: Int? = 7, handler: @escaping (URLRequest) throws -> (Int, [String: String], Data)) async throws -> LiveAccountRepository { + StubProtocol.lock.lock(); StubProtocol.handler = handler; StubProtocol.lock.unlock() + let config = DeviceSession.configuration(); config.protocolClasses = [StubProtocol.self] + let service = SpaceWorksService(origin: origin, session: DeviceSession.make(configuration: config), initialTokens: .init(access: "account-access", refresh: "account-refresh")) + try await service.makerspace.select(makerspaceID, activeMemberships: makerspaceID == nil ? [] : [7, 9]) + return .init(service: service, expectedCredentials: await service.refresh.snapshot(), makerspaceID: makerspaceID, origin: origin) + } + private func json(_ value: String, status: Int = 200) -> (Int, [String: String], Data) { (status, ["Content-Type": "application/json"], Data(value.utf8)) } + private func data(_ value: String, changes: [String: Any]) throws -> String { + var object = try JSONSerialization.jsonObject(with: Data(value.utf8)) as! [String: Any]; object.merge(changes) { _, new in new } + return String(decoding: try JSONSerialization.data(withJSONObject: object), as: UTF8.self) + } + private var me: String { #"{"id":51,"username":"meera","email":"meera@example.test","display_name":"Meera Nair","phone":"","email_verified":true,"role":"member","is_superuser":false,"must_change_password":false,"makerspaces":[]}"# } + private func archiveBytes() throws -> Data { + let object = try JSONSerialization.jsonObject(with: Data(DebugAccountFixtures.archive.utf8)) as! [String: String] + return try XCTUnwrap(Data(base64Encoded: object["base64"]!)) + } + func testGlobalSecurityOwnerCanCleanNativePushAndRestoreGlobalHeaderScope() async throws { + let count = AccountCallCounter() + let repo = try await repository { request in + count.increment() + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer account-access") + if request.httpMethod == "DELETE" { + XCTAssertEqual(request.url?.path, "/api/v1/integrations/push/devices/901") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Makerspace-Id"), "9") + return (204, [:], Data()) + } + XCTAssertEqual(request.url?.path, "/api/v1/auth/me") + XCTAssertNil(request.value(forHTTPHeaderField: "X-Makerspace-Id")) + return self.json(self.me) + } + let push = LivePushRegistrationRepository(service: repo.service, expectedCredentials: repo.expectedCredentials) + try await repo.service.withSession(expectedCredentials: repo.expectedCredentials) { + try await push.unregister(makerspaceID: 9, deviceID: 901, authorizedDirectMembershipIDs: [7, 9]) + _ = try await repo.service.me() + do { + _ = try await repo.service.withPushMakerspace(9, authorizedDirectMembershipIDs: [7, 9], expectedCredentials: repo.expectedCredentials) { try await repo.service.me() } + XCTFail("Narrow cleanup scope allowed an ordinary account operation") + } catch { XCTAssertTrue(PushOperationFailure.isCancellation(error)) } + } + XCTAssertEqual(count.value, 2) + let selected = await repo.service.makerspace.current(); XCTAssertEqual(selected, 7) + } + func testPasswordChangeUsesExistingGeneratedGlobalOperationAndDoesNotInventTokenRotation() async throws { + let count = AccountCallCounter() + let repo = try await repository(makerspaceID: nil) { request in + count.increment() + if request.url?.path == "/api/v1/auth/me" { + XCTAssertEqual(request.httpMethod, "GET"); XCTAssertNil(request.value(forHTTPHeaderField: "X-Makerspace-Id")) + return self.json(self.me) + } + XCTAssertEqual(request.url?.path, "/api/v1/auth/change-password") + XCTAssertEqual(request.httpMethod, "POST"); XCTAssertNil(request.value(forHTTPHeaderField: "X-Makerspace-Id")) + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer account-access") + let body = try XCTUnwrap(JSONSerialization.jsonObject(with: requestBody(request)) as? [String: String]) + XCTAssertEqual(body, ["current_password": "DemoPass!123", "new_password": "NewDemoPassword!"]) + return self.json(#"{"detail":"Password updated."}"#) + } + try await repo.changePassword(current: "DemoPass!123", new: "NewDemoPassword!") + let pair = await repo.service.refresh.current(); XCTAssertEqual(pair, TokenPair(access: "account-access", refresh: "account-refresh")); XCTAssertEqual(count.value, 2) + } + func testPasswordAcknowledgementThenIdentity401RemainsSessionRefusal() async throws { + let ended = AccountCallCounter() + var repo = try await repository(makerspaceID: nil) { request in + if request.url?.path == "/api/v1/auth/change-password" { return self.json(#"{"detail":"Password updated."}"#) } + if request.url?.path == "/api/v1/auth/device/refresh" { return self.json(#"{"access":"rotated-access","refresh":"rotated-refresh"}"#) } + XCTAssertEqual(request.url?.path, "/api/v1/auth/me") + return self.json(#"{"detail":"Session revoked."}"#, status: 401) + } + repo.onUnauthorized = { _ in ended.increment() } + do { try await repo.changePassword(current: "DemoPass!123", new: "NewDemoPassword!"); XCTFail("Expired identity accepted after acknowledgement") } + catch { XCTAssertEqual(APIError.status(error), 401); XCTAssertNotEqual(APIError.code(error), "password_verification_failed") } + XCTAssertEqual(ended.value, 1) + } + func testAll21GeneratedAccountOperationsUseExactBodiesAndPublicArchiveHasNoAccountCredentials() async throws { + var calls = Set(); var grantsIssued = 0 + let zip = try archiveBytes() + let repo = try await repository { request in + let path = request.url!.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")); let method = request.httpMethod! + calls.insert(method + " " + path) + if path.contains("data-exports/download/") { + XCTAssertEqual(method, "GET"); XCTAssertTrue(path.hasSuffix("one-use-2")) + for key in ["Authorization", "X-Makerspace-Id", "Cookie", "Origin", "Referer"] { XCTAssertNil(request.value(forHTTPHeaderField: key)) } + return (200, ["Content-Type": "application/zip"], zip) + } + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer account-access") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Makerspace-Id"), path.hasPrefix("api/v1/auth/") ? nil : "7") + for key in ["Cookie", "Origin", "Referer"] { XCTAssertNil(request.value(forHTTPHeaderField: key)) } + if method == "DELETE" { + XCTAssertTrue(requestBody(request).isEmpty) + if path.hasSuffix("auth/phone") { return self.json(#"{"phone_e164":"","verified":false}"#) } + return (204, [:], Data()) + } + if path.hasSuffix("auth/device/grants") { return self.json(DebugAccountFixtures.grants) } + if path.hasSuffix("account/sessions") { XCTAssertNil(request.url?.query); return self.json(DebugAccountFixtures.sessions) } + if path.hasSuffix("account/membership") { return self.json(DebugAccountFixtures.membership) } + if path.hasSuffix("account/leave") || path.hasSuffix("account/cancel") { + let body = try JSONSerialization.jsonObject(with: requestBody(request)) as! [String: Any] + XCTAssertEqual(body["reason"] as? String, "Moving away"); return self.json(DebugAccountFixtures.membership) + } + if path.hasSuffix("account/exports") { + if method == "POST" { + let body = try JSONSerialization.jsonObject(with: requestBody(request)) as! [String: Any]; XCTAssertTrue(body.isEmpty) + return self.json(DebugAccountFixtures.export, status: 202) + } + XCTAssertNil(request.url?.query); return self.json("{\"count\":1,\"next\":null,\"previous\":null,\"results\":[\(DebugAccountFixtures.export)]}") + } + if path.hasSuffix("/\(self.jobID)/download") { + XCTAssertEqual(method, "POST"); XCTAssertTrue(requestBody(request).isEmpty); grantsIssued += 1 + return self.json("{\"url\":\"https://spaceworks.example/api/v1/data-exports/download/\(self.jobID)/one-use-\(grantsIssued)\",\"expires_at\":\"2099-01-01T00:00:00Z\"}") + } + if path.hasSuffix("account/exports/\(self.jobID)") { return self.json(DebugAccountFixtures.export) } + if path.hasSuffix("auth/mfa") { return self.json(method == "POST" ? DebugAccountFixtures.challenge : DebugAccountFixtures.mfa) } + if path.hasSuffix("mfa/disable") || path.hasSuffix("mfa/recovery") { + let body = try JSONSerialization.jsonObject(with: requestBody(request)) as! [String: Any]; XCTAssertEqual(body["password"] as? String, "fixture-password") + return self.json(path.hasSuffix("recovery") ? DebugAccountFixtures.recovery : DebugAccountFixtures.mfa) + } + if path.hasSuffix("phone/link/start") || path.hasSuffix("phone/link/confirm") { + let body = try JSONSerialization.jsonObject(with: requestBody(request)) as! [String: Any]; XCTAssertEqual(body["phone"] as? String, "+919876543210") + if path.hasSuffix("confirm") { XCTAssertEqual(body["code"] as? String, "123456"); return self.json(#"{"phone_e164":"+919876543210","verified":true}"#) } + return self.json(#"{"detail":"Code sent."}"#) + } + if path.hasSuffix("social/providers") { return self.json("[\(DebugAccountFixtures.provider)]") } + XCTFail("Unexpected account route \(method) \(path)"); return self.json("{}", status: 500) + } + _ = try await repo.grants(); try await repo.revokeGrant(id: grantID) + _ = try await repo.sessions(page: 1); try await repo.revokeSession(id: "web:sample-other-session") + _ = try await repo.membership(); _ = try await repo.leave(reason: "Moving away"); _ = try await repo.cancelRenewal(reason: "Moving away") + _ = try await repo.exports(page: 1); _ = try await repo.requestExport(); _ = try await repo.export(id: jobID); _ = try await repo.exportDownload(id: jobID) + let bytes = try await repo.exportArchive(id: jobID); XCTAssertEqual(bytes, zip) + _ = try await repo.mfa() + _ = try await repo.service.withSession { try await repo.service.accountStartMFA(password: "fixture-password") } // Generated seam; native UI/repository gates it. + _ = try await repo.disableMFA(password: "fixture-password"); _ = try await repo.regenerateRecovery(password: "fixture-password") + _ = try await repo.startPhone(phone: "+919876543210"); _ = try await repo.confirmPhone(phone: "+919876543210", code: "123456"); _ = try await repo.unlinkPhone() + _ = try await repo.providers(); try await repo.unlinkProvider("google") + XCTAssertEqual(calls.count, 21); XCTAssertEqual(grantsIssued, 2) + } + func testGlobalScopeStripsSelectedMakerAndCannotEscapeToMemberOrPushOverride() async throws { + let count = AccountCallCounter() + let repo = try await repository { request in + count.increment(); XCTAssertNil(request.value(forHTTPHeaderField: "X-Makerspace-Id")) + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer account-access") + return self.json(self.me) + } + _ = try await repo.service.withSession { try await repo.service.me() } + do { _ = try await repo.service.withSession { try await repo.service.memberPayments(makerspaceID: 7) }; XCTFail("Global scope escaped to member data") } + catch { XCTAssertTrue(AccountOperationFailure.isCancellation(error)) } + do { + _ = try await repo.service.withPushMakerspace(9, authorizedDirectMembershipIDs: [7, 9]) { + try await repo.service.withSession { try await repo.service.me() } + } + XCTFail("Push override escaped into global security") + } catch { XCTAssertTrue(AccountOperationFailure.isCancellation(error)) } + XCTAssertEqual(count.value, 1) + let selected = await repo.service.makerspace.current(); XCTAssertEqual(selected, 7) + } + func testQueuedGlobalSecurityActionCannotSendUnderReplacementAccount() async throws { + let gate = AccountScopeGate(); let count = AccountCallCounter() + let repo = try await repository { _ in count.increment(); return self.json(DebugAccountFixtures.mfa) } + let old = Task { try await repo.service.withSession { await gate.arrive(); return try await repo.service.withSession { try await repo.service.accountMFA() } } } + await gate.entered() + try await repo.service.refresh.install(.init(access: "new-account", refresh: "new-refresh")) + await gate.release() + do { _ = try await old.value; XCTFail("Old security action sent under replacement account") } + catch { XCTAssertTrue(AccountOperationFailure.isCancellation(error)) } + XCTAssertEqual(count.value, 0) + } + func testRetainedAccountRepositoryCannotSendOrEndReplacementSession() async throws { + let count = AccountCallCounter(), ended = AccountCallCounter() + var repo = try await repository(makerspaceID: nil) { _ in count.increment(); return self.json(DebugAccountFixtures.mfa) } + repo.onUnauthorized = { _ in ended.increment() } + try await repo.service.refresh.install(.init(access: "replacement-access", refresh: "replacement-refresh")) + do { _ = try await repo.mfa(); XCTFail("Retained repository used replacement credentials") } + catch { XCTAssertTrue(AccountOperationFailure.isCancellation(error)) } + do { _ = try await repo.regenerateRecovery(password: "old-password"); XCTFail("Old management mutation used replacement account") } + catch { XCTAssertTrue(AccountOperationFailure.isCancellation(error)) } + XCTAssertEqual(count.value, 0); XCTAssertEqual(ended.value, 0) + } + func testLateManagement401NeverProbesOrSignsOutReplacementAccount() async throws { + let finalResponse = AccountResponseGate(), calls = AccountCallCounter(), ended = AccountCallCounter(), probes = AccountCallCounter() + var repo = try await repository(makerspaceID: nil) { request in + if request.url?.path == "/api/v1/auth/device/refresh" { return self.json(#"{"access":"rotated-access","refresh":"rotated-refresh"}"#) } + if request.url?.path == "/api/v1/auth/me" { probes.increment(); return self.json(self.me) } + XCTAssertEqual(request.url?.path, "/api/v1/auth/mfa/recovery") + calls.increment() + if calls.value == 2 { finalResponse.block() } + return self.json(#"{"detail":"Password verification failed."}"#, status: 401) + } + repo.onUnauthorized = { _ in ended.increment() } + let owner = repo + let old = Task { try await owner.regenerateRecovery(password: "old-password") } + await finalResponse.entered() + try await repo.service.refresh.install(.init(access: "replacement-access", refresh: "replacement-refresh")) + finalResponse.release() + do { _ = try await old.value; XCTFail("Old management result accepted") } + catch { XCTAssertTrue(AccountOperationFailure.isCancellation(error)) } + XCTAssertEqual(calls.value, 2); XCTAssertEqual(probes.value, 0); XCTAssertEqual(ended.value, 0) + let pair = await repo.service.refresh.current(); XCTAssertEqual(pair?.access, "replacement-access") + } + func testGlobalOperationsUseBearerWithoutMakerWhenThereIsNoMembership() async throws { + let count = AccountCallCounter() + let repo = try await repository(makerspaceID: nil) { request in + count.increment(); XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer account-access") + XCTAssertNil(request.value(forHTTPHeaderField: "X-Makerspace-Id")) + let path = request.url!.path + if path.hasSuffix("/grants") { return self.json(DebugAccountFixtures.grants) } + if path.hasSuffix("/mfa") { return self.json(DebugAccountFixtures.mfa) } + if path.hasSuffix("/providers") { return self.json("[\(DebugAccountFixtures.provider)]") } + if path.hasSuffix("/me") { return self.json(self.me) } + XCTFail("Unexpected global account operation"); return self.json("{}", status: 500) + } + _ = try await repo.grants(); _ = try await repo.mfa(); _ = try await repo.providers(); try await repo.validateSession() + XCTAssertEqual(count.value, 4) + do { _ = try await repo.sessions(page: 1); XCTFail("No-maker member session request") } catch {} + do { _ = try await repo.membership(); XCTFail("No-maker membership request") } catch {} + do { _ = try await repo.exports(page: 1); XCTFail("No-maker export request") } catch {} + do { _ = try await repo.leave(reason: "Moving away"); XCTFail("No-maker leave request") } catch {} + XCTAssertEqual(count.value, 4) + } + @MainActor func testVeryLargeRetryAfterRetainsDeadlineWithoutOverflowOrRepeatedRequests() async throws { + let count = AccountCallCounter() + let repo = try await repository(makerspaceID: nil) { _ in + count.increment() + return (429, ["Content-Type": "application/json", "Retry-After": "315360000000"], Data(#"{"detail":"Try again later."}"#.utf8)) + } + let state = AccountState(repository: repo, makerspaceID: nil, makerspaceName: "") + await state.loadSecurity(); await Task.yield() + XCTAssertFalse(state.canRequest); XCTAssertGreaterThan(state.retryAfter, Date().addingTimeInterval(315_000_000_000)) + await state.loadSecurity(); await state.loadGrants(); XCTAssertEqual(count.value, 1) + state.invalidate() + } + @MainActor func testResource403PurgesPrivateDataButDoesNotPermanentlyDisableValidGlobalAccount() async throws { + let repo = try await repository(makerspaceID: nil) { request in + if request.url?.path.hasSuffix("/grants") == true { return self.json(#"{"detail":"Device management is unavailable."}"#, status: 403) } + return self.json(DebugAccountFixtures.mfa) + } + let state = AccountState(repository: repo, makerspaceID: nil, makerspaceName: "") + await state.loadSecurity(); XCTAssertNotNil(state.mfa) + await state.loadGrants(); XCTAssertTrue(state.isActive); XCTAssertNil(state.mfa); XCTAssertTrue(state.grants.isEmpty) + await state.loadSecurity(); XCTAssertNotNil(state.mfa); XCTAssertEqual(state.securityLoad, .ready) + } + func testLiveMFAStartAndUndocumentedPaginationFailBeforeAnyRequest() async throws { + let count = AccountCallCounter() + let repo = try await repository { _ in count.increment(); return self.json("{}") } + do { _ = try await repo.startMFA(password: "fixture-password"); XCTFail("Native start bypassed browser-only backend gate") } + catch { XCTAssertEqual(APIError.code(error), "native_mfa_management_unavailable") } + do { _ = try await repo.sessions(page: 2); XCTFail("Invented session query") } catch { XCTAssertEqual(APIError.code(error), "account_pagination_unavailable") } + do { _ = try await repo.exports(page: 2); XCTFail("Invented export query") } catch { XCTAssertEqual(APIError.code(error), "account_pagination_unavailable") } + XCTAssertEqual(count.value, 0) + } + @MainActor func testWrongPassword401RefreshesThenProbesIdentityWithoutDestroyingAccountState() async throws { + let ended = AccountCallCounter(); let refreshes = AccountCallCounter(); let checks = AccountCallCounter() + var repo = try await repository { request in + if request.url?.path == "/api/v1/auth/device/refresh" { refreshes.increment(); return self.json(#"{"access":"rotated-access","refresh":"rotated-refresh"}"#) } + if request.url?.path == "/api/v1/auth/me" { checks.increment(); return self.json(self.me) } + if request.url?.path == "/api/v1/auth/mfa" { return self.json(DebugAccountFixtures.mfa) } + XCTAssertEqual(request.url?.path, "/api/v1/auth/mfa/recovery") + return self.json(#"{"detail":"Password verification failed."}"#, status: 401) + } + repo.onUnauthorized = { _ in ended.increment() } + let state = AccountState(repository: repo, makerspaceID: 7, makerspaceName: "Sample", endSession: { _, _ in ended.increment() }) + await state.loadSecurity(); state.password = "incorrect"; await state.regenerateRecovery() + XCTAssertTrue(state.isActive); XCTAssertNotNil(state.mfa); XCTAssertNotNil(state.error); XCTAssertEqual(state.password, "") + XCTAssertEqual(ended.value, 0); XCTAssertEqual(refreshes.value, 1); XCTAssertEqual(checks.value, 1) + } + @MainActor func testUnavailablePasswordIdentityProbeKeepsAccountActionableAndHonorsRetryAfter() async throws { + let ended = AccountCallCounter(), count = AccountCallCounter() + var repo = try await repository(makerspaceID: nil) { request in + count.increment() + if request.url?.path == "/api/v1/auth/device/refresh" { return self.json(#"{"access":"rotated-access","refresh":"rotated-refresh"}"#) } + if request.url?.path == "/api/v1/auth/me" { return (503, ["Content-Type": "application/json", "Retry-After": "17"], Data(#"{"detail":"Identity is temporarily unavailable."}"#.utf8)) } + if request.url?.path == "/api/v1/auth/mfa" { return self.json(DebugAccountFixtures.mfa) } + return self.json(#"{"detail":"Password verification failed."}"#, status: 401) + } + repo.onUnauthorized = { _ in ended.increment() } + let state = AccountState(repository: repo, makerspaceID: nil, makerspaceName: "") + await state.loadSecurity(); state.password = "incorrect"; await state.regenerateRecovery() + XCTAssertTrue(state.isActive); XCTAssertNotNil(state.mfa); XCTAssertEqual(state.error, "Identity is temporarily unavailable.") + XCTAssertGreaterThan(state.retryAfter, Date().addingTimeInterval(15)); XCTAssertEqual(ended.value, 0) + let calls = count.value; state.password = "incorrect"; await state.regenerateRecovery(); XCTAssertEqual(count.value, calls) + state.invalidate() + } + func testOldAccountScopeCannotRevokeAnySessionAfterMakerspaceSwitch() async throws { + let count = AccountCallCounter(); let repo = try await repository { _ in count.increment(); return (204, [:], Data()) } + try await repo.service.makerspace.select(9, activeMemberships: [7, 9]) + do { try await repo.revokeSession(id: "web:any-opaque-session"); XCTFail("Old scope sent a revoke") } catch { XCTAssertEqual(count.value, 0) } + } + func testForeignOrExpiredExportGrantNeverReachesItsDownloadURL() async throws { + for url in ["https://evil.example/api/v1/data-exports/download/\(jobID)/token", "https://spaceworks.example/api/v1/data-exports/download/\(jobID)/token?copy=1", "https://spaceworks.example/api/v1/data-exports/download/\(jobID)/%2E%2E"] { + let count = AccountCallCounter() + let repo = try await repository { request in + count.increment(); XCTAssertTrue(request.url!.path.hasSuffix("/download")) + return self.json("{\"url\":\"\(url)\",\"expires_at\":\"2099-01-01T00:00:00Z\"}") + } + do { _ = try await repo.exportArchive(id: jobID); XCTFail("Unsafe grant consumed") } catch { XCTAssertEqual(count.value, 1) } + } + } + func testOctetStreamArchiveUsesSameGeneratedPublicOperation() async throws { + let zip = try archiveBytes(); let repo = try await repository { request in + if request.url!.path.hasSuffix("/download") { return self.json("{\"url\":\"https://spaceworks.example/api/v1/data-exports/download/\(self.jobID)/one-use\",\"expires_at\":\"2099-01-01T00:00:00Z\"}") } + XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")); return (200, ["Content-Type": "application/octet-stream"], zip) + } + let result = try await repo.exportArchive(id: jobID); XCTAssertEqual(result, zip) + } +} + +private final class AccountCallCounter: @unchecked Sendable { + private let lock = NSLock(); private var count = 0 + var value: Int { lock.lock(); defer { lock.unlock() }; return count } + func increment() { lock.lock(); count += 1; lock.unlock() } +} + +private actor AccountScopeGate { + private var started = false + private var continuation: CheckedContinuation? + private var observer: CheckedContinuation? + func arrive() async { started = true; observer?.resume(); observer = nil; await withCheckedContinuation { continuation = $0 } } + func entered() async { if !started { await withCheckedContinuation { observer = $0 } } } + func release() { continuation?.resume(); continuation = nil } +} + +private final class AccountResponseGate: @unchecked Sendable { + private let lock = NSLock() + private let response = DispatchSemaphore(value: 0) + private var started = false + private var observer: CheckedContinuation? + func block() { + lock.lock(); started = true; let observer = self.observer; self.observer = nil; lock.unlock() + observer?.resume() + _ = response.wait(timeout: .now() + 10) + } + func entered() async { + await withCheckedContinuation { continuation in + lock.lock() + if started { lock.unlock(); continuation.resume() } + else { observer = continuation; lock.unlock() } + } + } + func release() { response.signal() } +} diff --git a/SpaceWorksTests/AccountTests.swift b/SpaceWorksTests/AccountTests.swift new file mode 100644 index 0000000..b826c6d --- /dev/null +++ b/SpaceWorksTests/AccountTests.swift @@ -0,0 +1,206 @@ +import Foundation +import SpaceWorksAPI +import XCTest +@testable import SpaceWorks + +#if DEBUG +@MainActor final class AccountTests: XCTestCase { + private func state(_ repo: DemoAccountRepository = DemoAccountRepository(), cooldown: AccountAccessCooldown? = nil, + now: @escaping () -> Date = Date.init, + currentID: String? = nil, end: @escaping ([String], String?) async -> Void = { _, _ in }, + left: @escaping () async -> Void = {}) -> AccountState { + AccountState(repository: repo, makerspaceID: 7, makerspaceName: "Sample makerspace", selfServiceEnabled: true, exportEnabled: true, currentDeviceGrantID: currentID, + phoneCooldown: cooldown, now: now, endSession: end, membershipEnded: left) + } + func testExportBootstrapFeatureIsIndependentOfMembershipSelfServiceAndRequiresBothModules() async throws { + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(DebugMemberFixtures.bootstrap.utf8)) as? [String: Any]) + object["modules"] = ["membership", "member_accounts"] + object["features"] = ["membership.self_export"] + var source = String(decoding: try JSONSerialization.data(withJSONObject: object), as: UTF8.self) + let exportOnly = MemberCapabilities(repository: DemoMemberActionsRepository(bootstrapJSON: source), makerspaceID: 7) + await exportOnly.load(); XCTAssertTrue(exportOnly.exports); XCTAssertFalse(exportOnly.selfService) + object["modules"] = ["membership"] + source = String(decoding: try JSONSerialization.data(withJSONObject: object), as: UTF8.self) + let missingModule = MemberCapabilities(repository: DemoMemberActionsRepository(bootstrapJSON: source), makerspaceID: 7) + await missingModule.load(); XCTAssertFalse(missingModule.exports) + } + func testNoMembershipStillSupportsGlobalSecurityProvidersAndDevices() async { + let account = AccountState(repository: DemoAccountRepository(), makerspaceID: nil, makerspaceName: "", selfServiceEnabled: true, exportEnabled: true) + await account.loadSecurity(); await account.loadProviders(); await account.loadGrants() + XCTAssertNotNil(account.mfa); XCTAssertFalse(account.providers.isEmpty); XCTAssertFalse(account.grants.isEmpty) + await account.loadSessions(); await account.loadMembership(); await account.loadExports() + XCTAssertFalse(account.memberSelfServiceAvailable); XCTAssertFalse(account.dataExportAvailable) + XCTAssertEqual(account.sessionsLoad, .idle); XCTAssertNil(account.membership); XCTAssertEqual(account.exportsLoad, .idle) + } + func testBootstrapDisablingMemberServicesClearsTheirDataButRetainsGlobalAccountControls() async { + let account = state(); await account.loadSessions(); await account.loadMembership(); await account.loadExports() + await account.loadSecurity(); await account.loadGrants() + XCTAssertFalse(account.sessions.isEmpty); XCTAssertFalse(account.exports.isEmpty); XCTAssertNotNil(account.membership) + account.updateCapabilities(selfService: false, exports: false) + XCTAssertTrue(account.sessions.isEmpty); XCTAssertTrue(account.exports.isEmpty); XCTAssertNil(account.membership); XCTAssertNil(account.archive) + XCTAssertNotNil(account.mfa); XCTAssertFalse(account.grants.isEmpty); XCTAssertTrue(account.isActive) + } + func testPasswordChangeNeedsMatchingFieldsThenRefreshesIdentityOnlyAfterAcknowledgement() async { + var refreshed = 0 + let account = AccountState(repository: DemoAccountRepository(), makerspaceID: nil, makerspaceName: "", passwordChanged: { refreshed += 1 }) + let mismatch = await account.changePassword(current: "DemoPass!123", new: "NewDemoPassword!", confirmation: "Different") + XCTAssertFalse(mismatch); XCTAssertEqual(refreshed, 0) + let wrong = await account.changePassword(current: "incorrect", new: "NewDemoPassword!", confirmation: "NewDemoPassword!") + XCTAssertFalse(wrong); XCTAssertEqual(refreshed, 0); XCTAssertTrue(account.isActive) + let updated = await account.changePassword(current: "DemoPass!123", new: "NewDemoPassword!", confirmation: "NewDemoPassword!") + XCTAssertTrue(updated); XCTAssertEqual(refreshed, 1); XCTAssertEqual(account.notice, "Password change preview complete. No password was changed.") + } + func testWrongManagementPasswordPreservesKnownValidSessionAndClearsPassword() async { + var ended = false + let account = state(end: { _, _ in ended = true }); await account.loadSecurity() + account.password = "wrong"; await account.regenerateRecovery() + XCTAssertFalse(ended); XCTAssertTrue(account.isActive); XCTAssertEqual(account.password, "") + XCTAssertNotNil(account.error); XCTAssertNotNil(account.mfa) + } + func testRecoveryReplacementClearsOldAccountStateBeforeOneTimeCodesAreHandedToOwner() async { + var codes: [String] = []; var ended = false + let account = state(end: { values, _ in codes = values; ended = true }) + await account.loadSecurity(); await account.loadSessions(); account.password = "DemoPass!123" + await account.regenerateRecovery() + XCTAssertTrue(ended); XCTAssertEqual(codes.count, 3); XCTAssertFalse(account.isActive) + XCTAssertTrue(account.sessions.isEmpty); XCTAssertNil(account.mfa); XCTAssertEqual(account.password, "") + } + func testRequiredMFAHidesAndRefusesDisable() async { + var ended = false + let account = state(DemoAccountRepository(scenario: .requiredMFA), end: { _, _ in ended = true }) + await account.loadSecurity(); account.password = "DemoPass!123" + XCTAssertFalse(account.canDisableMFA); await account.disableAuthenticator() + XCTAssertFalse(ended); XCTAssertTrue(account.mfa?.enabled == true) + } + func testCurrentSessionUsesServerCurrentFlagAndEndsLocalAccess() async throws { + var ended = false + let account = state(end: { _, _ in ended = true }); await account.loadSessions() + let row = try XCTUnwrap(account.sessions.first { $0.current }); await account.revokeSession(row) + XCTAssertTrue(ended); XCTAssertFalse(account.isActive); XCTAssertTrue(account.sessions.isEmpty) + } + func testOtherSessionRevocationKeepsCurrentSession() async throws { + var ended = false + let account = state(end: { _, _ in ended = true }); await account.loadSessions() + let row = try XCTUnwrap(account.sessions.first { !$0.current }); await account.revokeSession(row) + XCTAssertFalse(ended); XCTAssertTrue(account.isActive); XCTAssertFalse(account.sessions.contains { $0.id == row.id }) + XCTAssertTrue(account.sessions.contains { $0.current }) + } + func testUnknownCurrentGrantIsVerifiedAfterRevocationAndRevokedSessionSignsOut() async throws { + var ended = false + let account = state(end: { _, _ in ended = true }); await account.loadGrants() + let row = try XCTUnwrap(account.grants.first { $0.id == "83000000-0000-4000-8000-000000000001" }) + await account.revokeGrant(row); XCTAssertTrue(ended); XCTAssertFalse(account.isActive) + } + func testOtherDeviceRevocationValidatesAndKeepsActiveSession() async throws { + var ended = false + let account = state(end: { _, _ in ended = true }); await account.loadGrants() + let row = try XCTUnwrap(account.grants.first { $0.id == "83000000-0000-4000-8000-000000000002" }) + await account.revokeGrant(row); XCTAssertFalse(ended); XCTAssertTrue(account.isActive) + XCTAssertFalse(account.grants.contains { $0.id == row.id }) + } + func testPhoneCodeMustBeVerifiedBeforeDisplayingLinkedPhone() async { + let account = state(); account.phone = "9876543210"; await account.sendPhoneCode() + XCTAssertNil(account.phoneStatus); XCTAssertEqual(account.pendingPhone, "+919876543210") + account.code = "000000"; await account.confirmPhone() + XCTAssertNil(account.phoneStatus); XCTAssertEqual(account.code, ""); XCTAssertEqual(account.error, "Invalid or expired verification code.") + account.code = "123456"; await account.confirmPhone() + XCTAssertTrue(account.phoneStatus?.verified == true); XCTAssertEqual(account.phoneStatus?.phone_e164, "+919876543210"); XCTAssertNil(account.pendingPhone) + } + func testPhoneResendCooldownSurvivesFormAndMakerspaceStateRecreation() async { + let cooldown = AccountAccessCooldown() + let first = state(cooldown: cooldown); first.phone = "9876543210"; await first.sendPhoneCode() + let deadline = first.resendAfter; first.changePhoneNumber() + let second = state(cooldown: cooldown); second.phone = "9876543210"; await second.sendPhoneCode() + XCTAssertEqual(second.resendAfter, deadline); XCTAssertNil(second.pendingPhone) + } + func testPhoneValidationRejectsNonDigitsAndTooLongInternationalNumber() async { + let account = state(); account.phoneCountry = "+00"; account.phone = "9876543210"; await account.sendPhoneCode() + XCTAssertNotNil(account.error); XCTAssertNil(account.pendingPhone) + account.phoneCountry = "+91"; account.phone = "9876543210123456"; await account.sendPhoneCode(); XCTAssertNil(account.pendingPhone) + } + func testPhoneAutofillAcceptsFormattedNationalAndFullInternationalWithoutDuplicatingCountry() { + XCTAssertEqual(AccountState.canonicalPhone(callingCode: "+91", number: "+91 98765 43210"), "+919876543210") + XCTAssertEqual(AccountState.canonicalPhone(callingCode: "+91", number: "(98765) 432-10"), "+919876543210") + XCTAssertEqual(AccountState.canonicalPhone(callingCode: "91", number: "9876543210"), "+919876543210") + } + func testPhoneAutofillRefusesConflictingCountryUnicodeDigitsAndEmbeddedPlus() { + for number in ["+44 7700 900123", "९८७६५४३२१०", "987+6543210"] { + XCTAssertNil(AccountState.canonicalPhone(callingCode: "+91", number: number)) + } + } + func testPhoneResendUsesExactPendingCanonicalNumberUntilUserChoosesChange() async { + let cooldown = AccountAccessCooldown() + let account = state(cooldown: cooldown); account.phone = "+91 98765 43210" + await account.sendPhoneCode(); XCTAssertEqual(account.pendingPhone, "+919876543210") + cooldown.resendAfter = .distantPast; account.phoneCountry = "+44"; account.phone = "7700900123" + await account.sendPhoneCode(); XCTAssertEqual(account.pendingPhone, "+919876543210") + } + func testUnlinkPhoneUsesReturnedVerifiedStatus() async { + let account = state(); account.phone = "9876543210"; await account.sendPhoneCode(); account.code = "123456"; await account.confirmPhone() + await account.unlinkPhone(); XCTAssertTrue(account.phoneStatus?.verified == false); XCTAssertEqual(account.phoneStatus?.phone_e164, "") + } + func testProviderConflictAndCancellationKeepExistingMethods() async { + let account = state(); await account.loadProviders() + await account.previewProviderLink("apple", outcome: .conflict); XCTAssertEqual(account.providers.map(\.provider), ["google"]) + await account.previewProviderLink("apple", outcome: .cancelled); XCTAssertEqual(account.providers.map(\.provider), ["google"]) + await account.previewProviderLink("apple", outcome: .success); XCTAssertEqual(Set(account.providers.map(\.provider)), ["google", "apple"]) + } + func testLastProviderRemovalRefusalDoesNotOptimisticallyRemoveIt() async { + let account = state(); await account.loadProviders(); await account.unlinkProvider("google") + XCTAssertNotNil(account.error); XCTAssertEqual(account.providers.map(\.provider), ["google"]); XCTAssertTrue(account.isActive) + } + func testLeaveRefusalPreservesMembershipAndProvidesReason() async { + var left = false + let account = state(DemoAccountRepository(scenario: .blockedMembership), left: { left = true }) + await account.loadMembership(); account.membershipReason = "Moving away"; await account.leaveMakerspace() + XCTAssertFalse(left); XCTAssertEqual(account.membership?.status.rawValue, "active"); XCTAssertNotNil(account.error) + } + func testSelfServiceDisabledDuringPushCleanupAbortsLeaveAndReturnsOwnershipForSafeResume() async throws { + let repository = DemoAccountRepository(); var resumed = 0; var ended = false + var account: AccountState! + account = AccountState(repository: repository, makerspaceID: 7, makerspaceName: "Sample", selfServiceEnabled: true, + membershipEnded: { ended = true }, beforeLeave: { account.updateCapabilities(selfService: false, exports: false) }, leaveFailed: { resumed += 1 }) + await account.loadMembership(); account.membershipReason = "Moving away"; await account.leaveMakerspace() + let actual = try await repository.membership() + XCTAssertEqual(actual.status.rawValue, "active"); XCTAssertEqual(resumed, 1); XCTAssertFalse(ended) + XCTAssertNil(account.membership); XCTAssertFalse(account.memberSelfServiceAvailable) + } + func testCancellingRenewalKeepsMembershipWhileLeavingEndsScope() async { + var left = false + let account = state(left: { left = true }); await account.loadMembership(); account.membershipReason = "Renew manually" + await account.cancelRenewal(); XCTAssertEqual(account.membership?.status.rawValue, "active"); XCTAssertNotNil(account.membership?.renewal_cancelled_at); XCTAssertFalse(left) + account.membershipReason = "Moving away"; await account.leaveMakerspace(); XCTAssertTrue(left); XCTAssertFalse(account.isActive) + } + func testExportIsPendingUntilExplicitStatusCheckAndArchiveIsDeletedOnScopeEnd() async throws { + let account = state(); await account.requestExport() + let pending = try XCTUnwrap(account.exports.first); XCTAssertEqual(pending.status.value1.rawValue, "pending") + await account.downloadExport(pending); XCTAssertNil(account.archive) + await account.refreshExport(pending); let available = try XCTUnwrap(account.exports.first) + XCTAssertEqual(available.status.value1.rawValue, "available"); await account.downloadExport(available) + let url = try XCTUnwrap(account.archive?.url); XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + account.invalidate(); XCTAssertNil(account.archive); XCTAssertFalse(FileManager.default.fileExists(atPath: url.path)) + } + func testIdleExportDeadlineDisablesDownloadAndExpiredTapExplainsWithoutCreatingArchive() async throws { + var clock = Date() + let account = state(now: { clock }); await account.loadExports() + let row = try XCTUnwrap(account.exports.first) + clock = row.expires_at.addingTimeInterval(-1) + XCTAssertTrue(account.canDownloadExport(row)) + clock = row.expires_at + XCTAssertFalse(account.canDownloadExport(row)) + await account.downloadExport(row) + XCTAssertNil(account.archive); XCTAssertEqual(account.error, "This export expired. Request a new archive.") + } + func testRetryAfterBlocksRepeatedActionsAndInvalidationErasesSecrets() async { + let account = state(DemoAccountRepository(scenario: .throttled)); await account.loadMembership() + XCTAssertFalse(account.canRequest); XCTAssertGreaterThan(account.retryAfter, Date()) + account.password = "secret"; account.code = "123456"; account.phone = "9876543210"; account.invalidate() + XCTAssertEqual(account.password, ""); XCTAssertEqual(account.code, ""); XCTAssertEqual(account.phone, "") + } + func testHelpLinksRequireHTTPSAndRefuseCredentialURLs() { + XCTAssertNil(AccountLinks.safe(URL(string: "http://example.com/privacy"))) + XCTAssertNil(AccountLinks.safe(URL(string: "https://user:pass@example.com/privacy"))) + XCTAssertNotNil(AccountLinks.safe(URL(string: "https://example.com/privacy"))) + } +} +#endif diff --git a/SpaceWorksTests/AuthTests.swift b/SpaceWorksTests/AuthTests.swift index 247e180..5232e72 100644 --- a/SpaceWorksTests/AuthTests.swift +++ b/SpaceWorksTests/AuthTests.swift @@ -194,11 +194,11 @@ final class MemoryTokens: TokenStorage, @unchecked Sendable { auth.code = "123456"; await auth.verify() XCTAssertEqual(auth.stage, .signIn); XCTAssertEqual(auth.error, "Your sign-in expired. Try again.") } - func testTemporaryPasswordGateRefetchesAuthorityBeforeOpeningApp() async throws { + func testTemporaryPasswordAcknowledgementClearsPrivateStateAndRequiresFreshSignIn() async throws { let storage = MemoryTokens(TokenPair(access: "test-access", refresh: "test-refresh")) var changed = false let auth = state(storage: storage) { request in - if request.url!.path.hasSuffix("/me") { return (200, changed ? DebugAuthFixtures.user : DebugAuthFixtures.user.replacingOccurrences(of: "\"must_change_password\":false", with: "\"must_change_password\":true")) } + if request.url!.path.hasSuffix("/me") { XCTAssertFalse(changed, "Required update must reauthenticate, not silently reopen the app"); return (200, DebugAuthFixtures.user.replacingOccurrences(of: "\"must_change_password\":false", with: "\"must_change_password\":true")) } if request.url!.path.hasSuffix("/change-password") { let body = try JSONSerialization.jsonObject(with: requestBody(request)) as! [String: String] XCTAssertEqual(body, ["current_password": "temporary", "new_password": "replacement"]) @@ -208,9 +208,30 @@ final class MemoryTokens: TokenStorage, @unchecked Sendable { return try self.response(request) } await auth.restore(); XCTAssertEqual(auth.stage, .passwordChange) + var privateCleared = false; auth.registerPrivateHolder(UUID()) { privateCleared = true } await auth.changePassword(current: "temporary", new: "replacement", confirmation: "replacement") - XCTAssertEqual(auth.stage, .signedIn); XCTAssertNotNil(try storage.load()) - XCTAssertNil(auth.error) + XCTAssertTrue(changed); XCTAssertTrue(privateCleared) + XCTAssertEqual(auth.stage, .signIn); XCTAssertNil(try storage.load()); XCTAssertNil(auth.user); XCTAssertNil(auth.selectedID) + XCTAssertEqual(auth.error, "Password updated. Sign in again.") + } + func testWrongTemporaryPasswordPreservesVerifiedNativeSessionAndGate() async throws { + let storage = MemoryTokens(TokenPair(access: "test-access", refresh: "test-refresh")) + var checks = 0 + let auth = state(storage: storage) { request in + if request.url!.path.hasSuffix("/me") { + checks += 1 + return (200, DebugAuthFixtures.user.replacingOccurrences(of: "\"must_change_password\":false", with: "\"must_change_password\":true")) + } + if request.url!.path.hasSuffix("/change-password") { return (401, #"{"detail":"Current password is incorrect."}"#) } + if request.url!.path.hasSuffix("/refresh") { return (200, #"{"access":"rotated-access","refresh":"rotated-refresh"}"#) } + return try self.response(request) + } + await auth.restore(); XCTAssertEqual(auth.stage, .passwordChange) + var privateCleared = false; auth.registerPrivateHolder(UUID()) { privateCleared = true } + await auth.changePassword(current: "incorrect", new: "replacement", confirmation: "replacement") + XCTAssertFalse(privateCleared); XCTAssertEqual(auth.stage, .passwordChange); XCTAssertNotNil(auth.user) + XCTAssertNotNil(try storage.load()); XCTAssertEqual(auth.error, "Current password is incorrect."); XCTAssertEqual(checks, 2) + await auth.invalidateSession(message: "Test finished.") } func testDemoBlockedStatesAreExplicitAndResettable() { let demo = DemoSession() diff --git a/SpaceWorksTests/DemoAccountScopeTests.swift b/SpaceWorksTests/DemoAccountScopeTests.swift new file mode 100644 index 0000000..ecb6566 --- /dev/null +++ b/SpaceWorksTests/DemoAccountScopeTests.swift @@ -0,0 +1,55 @@ +#if DEBUG +import XCTest +import SpaceWorksAPI +@testable import SpaceWorks + +@MainActor final class DemoAccountScopeTests: XCTestCase { + func testZeroMembershipSignInStillOffersGlobalDeviceManagement() async { + let session = DemoSession(); session.scenario = .noMemberships; session.username = "member"; session.password = "sample" + session.signIn(); XCTAssertEqual(session.stage, .noMakerspaces); XCTAssertTrue(session.availableSpaces.isEmpty) + let account = session.accountState(makerspaceID: nil) + await account.loadGrants() + XCTAssertFalse(account.grants.isEmpty); XCTAssertFalse(account.memberSelfServiceAvailable); XCTAssertFalse(account.dataExportAvailable) + session.securityEnded(codes: [], message: "Sign in again."); session.finishSecurityTransition() + session.username = "member"; session.password = "sample"; session.signIn() + XCTAssertEqual(session.stage, .noMakerspaces, "Fresh sign-in must not strand a zero-membership account on an empty picker") + } + func testGlobalProvidersAndPhoneCooldownFollowAccountAcrossMakerspaces() async { + let session = DemoSession() + let first = session.accountState(makerspaceID: 7) + await first.previewProviderLink("apple", outcome: .success) + first.phone = "9876543210"; await first.sendPhoneCode() + let second = session.accountState(makerspaceID: 8); await second.loadProviders() + XCTAssertTrue(second.providers.contains { $0.provider == "apple" }) + XCTAssertEqual(first.resendAfter, second.resendAfter); XCTAssertGreaterThan(second.resendAfter, Date()) + session.reset() + XCTAssertEqual(session.accountState(makerspaceID: 7).resendAfter, .distantPast) + } + func testLeavingOneDemoMakerspaceDoesNotRevokeAnotherMembership() async throws { + let session = DemoSession(); session.stage = .shell + let first = session.accountState(makerspaceID: 7), second = session.accountState(makerspaceID: 8) + first.updateCapabilities(selfService: true, exports: true); second.updateCapabilities(selfService: true, exports: true) + await first.loadMembership() + let remaining = try await second.repository.membership() + XCTAssertEqual(remaining.status.rawValue, "active") + first.membershipReason = "Moving away"; await first.leaveMakerspace() + XCTAssertEqual(session.availableSpaces, [1]); XCTAssertEqual(session.stage, .makerspace) + XCTAssertEqual(session.selectedSpace, 1) + } + func testSecurityCompletionClearsSecretsAndRequiresExplicitReturn() { + let session = DemoSession(); session.stage = .shell; session.password = "secret"; session.code = "123456" + session.securityEnded(codes: ["one-use-demo"], message: "Save these codes.") + XCTAssertEqual(session.stage, .securityCompletion); XCTAssertEqual(session.password, ""); XCTAssertEqual(session.code, "") + XCTAssertEqual(session.securityCodes, ["one-use-demo"]) + session.finishSecurityTransition() + XCTAssertEqual(session.stage, .signIn); XCTAssertTrue(session.securityCodes.isEmpty); XCTAssertNil(session.securityMessage) + } + func testBlockerFixSelectsActualAuthorizedMakerspaceBeforeRouting() { + let session = DemoSession(); session.stage = .shell + let blocker = DemoDeletionState.sampleBlockers.first { $0.makerspace == "Maker Space Bengaluru" }! + session.routeAccountBlocker(blocker) + XCTAssertEqual(session.selectedSpace, 1); XCTAssertEqual(session.pendingAccountFix?.id, blocker.id) + session.reset(); XCTAssertNil(session.pendingAccountFix) + } +} +#endif diff --git a/SpaceWorksTests/DemoDeletionTests.swift b/SpaceWorksTests/DemoDeletionTests.swift new file mode 100644 index 0000000..6bfa4ef --- /dev/null +++ b/SpaceWorksTests/DemoDeletionTests.swift @@ -0,0 +1,42 @@ +#if DEBUG +import XCTest +@testable import SpaceWorks + +@MainActor final class DemoDeletionTests: XCTestCase { + func testAllMakerspaceBlockersIncludingUnknownDetailRemainVisibleAndCannotDelete() { + var deleted = false + let state = DemoDeletionState(onDelete: { deleted = true }); state.checkBlockers() + XCTAssertEqual(state.stage, .blockers); XCTAssertEqual(state.blockers.count, 13) + XCTAssertEqual(Set(state.blockers.map(\.makerspace)), ["Fab Lab Kochi", "Maker Space Bengaluru", "This server"]) + XCTAssertEqual(state.blockers.last?.detail, "A makerspace review must finish before deletion can proceed.") + state.credential = "DemoPass!123"; state.authenticate(); state.confirmation = "DELETE"; state.deletePreviewAccount() + XCTAssertFalse(deleted); XCTAssertFalse(state.canConfirm) + } + func testDeletionRequiresFreshProofExactTypedConfirmationAndOnlyClearsDemoOnce() { + var deletions = 0 + let state = DemoDeletionState(onDelete: { deletions += 1 }); state.scenario = .ready; state.checkBlockers() + state.confirmation = "DELETE"; state.deletePreviewAccount(); XCTAssertEqual(deletions, 0) + state.credential = "wrong"; state.authenticate(); XCTAssertEqual(state.stage, .reauthenticate); XCTAssertEqual(state.credential, "") + state.credential = "DemoPass!123"; state.authenticate(); XCTAssertEqual(state.stage, .confirm) + state.confirmation = "delete"; state.deletePreviewAccount(); XCTAssertEqual(deletions, 0) + state.confirmation = "DELETE"; state.deletePreviewAccount(); state.deletePreviewAccount() + XCTAssertEqual(deletions, 1); XCTAssertEqual(state.stage, .deleted); XCTAssertEqual(state.confirmation, "") + } + func testConfirmationConflictReplacesBlockersAndErasesReauthenticationProof() { + var deleted = false + let state = DemoDeletionState(onDelete: { deleted = true }); state.scenario = .conflict; state.checkBlockers() + state.credential = "DemoPass!123"; state.authenticate(); state.confirmation = "DELETE"; state.deletePreviewAccount() + XCTAssertEqual(state.stage, .blockers); XCTAssertEqual(state.blockers.map(\.id), ["active_loans"]) + XCTAssertFalse(state.canConfirm); XCTAssertFalse(deleted); XCTAssertEqual(state.confirmation, "") + state.checkBlockers(); XCTAssertEqual(state.stage, .blockers); XCTAssertEqual(state.blockers.map(\.id), ["active_loans"]) + } + func testExpiredProofRequiresIdentityAgainAndInvalidationCancelsDeletion() { + var clock = Date(); var deleted = false + let state = DemoDeletionState(now: { clock }, onDelete: { deleted = true }); state.scenario = .ready; state.checkBlockers() + state.method = .authenticator; state.credential = "123456"; state.authenticate(); state.confirmation = "DELETE" + clock = clock.addingTimeInterval(301); state.deletePreviewAccount(); XCTAssertEqual(state.stage, .reauthenticate) + state.credential = "123456"; state.authenticate(); state.confirmation = "DELETE"; state.invalidate(); state.deletePreviewAccount() + XCTAssertFalse(deleted); XCTAssertEqual(state.credential, ""); XCTAssertEqual(state.confirmation, "") + } +} +#endif diff --git a/SpaceWorksTests/DemoEmailVerificationTests.swift b/SpaceWorksTests/DemoEmailVerificationTests.swift new file mode 100644 index 0000000..e119963 --- /dev/null +++ b/SpaceWorksTests/DemoEmailVerificationTests.swift @@ -0,0 +1,30 @@ +#if DEBUG +import XCTest +@testable import SpaceWorks + +@MainActor final class DemoEmailVerificationTests: XCTestCase { + func testEmailPreviewRejectsWrongCodeAndUsesSharedResendCooldownBeforeContinuing() async { + let session = DemoSession(); session.username = "unverified"; session.password = "sample"; session.signIn() + XCTAssertEqual(session.stage, .emailVerification) + let form = session.emailVerificationForm(); await form.resendEmail() + XCTAssertEqual(form.notice, "Demo code ready. Use 123456. No email was sent.") + XCTAssertFalse(session.emailVerificationForm().canResend) + form.code = "000000"; await form.confirmEmail() + XCTAssertFalse(session.demoEmailConfirmed); XCTAssertEqual(session.stage, .emailVerification) + form.code = "123456"; await form.confirmEmail() + XCTAssertTrue(session.demoEmailConfirmed); XCTAssertEqual(form.notice, "Email confirmed."); XCTAssertTrue(form.code.isEmpty) + session.finishEmailVerification(expectedFlowID: session.emailFlowID); XCTAssertEqual(session.stage, .makerspace) + } + func testRetiredEmailFormCannotMutateANewUnverifiedFlowAfterReset() async { + let session = DemoSession(); session.username = "unverified"; session.password = "sample"; session.signIn() + let form = session.emailVerificationForm(); let retiredID = session.emailFlowID; session.reset() + XCTAssertEqual(session.stage, .welcome) + session.username = "unverified"; session.password = "sample"; session.signIn() + XCTAssertNotEqual(session.emailFlowID, retiredID); XCTAssertEqual(session.stage, .emailVerification) + form.code = "123456"; await form.confirmEmail(); await form.resendEmail() + session.finishEmailVerification(expectedFlowID: retiredID) + XCTAssertFalse(session.demoEmailConfirmed); XCTAssertEqual(session.stage, .emailVerification) + XCTAssertTrue(session.emailVerificationForm().canResend, "Retired resend must not affect the replacement flow") + } +} +#endif diff --git a/SpaceWorksTests/EmailOwnerTests.swift b/SpaceWorksTests/EmailOwnerTests.swift new file mode 100644 index 0000000..159c3a4 --- /dev/null +++ b/SpaceWorksTests/EmailOwnerTests.swift @@ -0,0 +1,192 @@ +#if DEBUG +import Foundation +import XCTest +import SpaceWorksAPI +@testable import SpaceWorks + +@MainActor final class EmailOwnerTests: XCTestCase { + override func tearDown() { EmailOwnerProtocol.server = nil } + + private func make(_ server: EmailOwnerServer) throws -> AuthState { + EmailOwnerProtocol.server = server + let configuration = DeviceSession.configuration(); configuration.protocolClasses = [EmailOwnerProtocol.self] + return AuthState(origin: try ServerOrigin("https://email-owner-" + UUID().uuidString.lowercased() + ".example"), + session: DeviceSession.make(configuration: configuration), settings: UserDefaults(suiteName: UUID().uuidString)!, + storage: MemoryTokens(.init(access: "old-access", refresh: "old-refresh")), + attest: { _ in Data(#"{"type":"development"}"#.utf8) }) + } + private func replace(_ auth: AuthState) async { + await auth.invalidateSession(message: "Test replacement.") + auth.username = "maker"; auth.password = "sample" + await auth.signIn(); await auth.select(8) + XCTAssertEqual(auth.stage, .signedIn); XCTAssertEqual(auth.selectedID, 8) + } + func testRetainedEmailFormCannotFirstSendUnderNewSameAccountInstallation() async throws { + let server = EmailOwnerServer(); let auth = try make(server); await auth.restore(); await auth.select(7) + let old = auth.accountForm(.emailVerification); old.code = "123456" + await replace(auth) + let baseline = server.requests.count + await old.confirmEmail(); await old.resendEmail() + XCTAssertEqual(server.requests.count, baseline); XCTAssertNil(old.notice) + XCTAssertTrue(auth.accountForm(.emailVerification).canResend) + XCTAssertEqual(auth.selectedID, 8); XCTAssertEqual(auth.stage, .signedIn) + await auth.invalidateSession(message: "Test finished.") + } + func testDelayedEmailConfirmationCannotRefreshReplacementAccount() async throws { + try await delayedAcknowledgement(confirm: true) + } + func testDelayedEmailResendCannotChangeReplacementCooldown() async throws { + try await delayedAcknowledgement(confirm: false) + } + private func delayedAcknowledgement(confirm: Bool) async throws { + let server = EmailOwnerServer(); server.holdPath = confirm ? EmailOwnerServer.confirm : EmailOwnerServer.resend + defer { server.release() } + let auth = try make(server); await auth.restore(); await auth.select(7) + let old = auth.accountForm(.emailVerification); old.code = "123456" + let request = Task { if confirm { await old.confirmEmail() } else { await old.resendEmail() } } + for _ in 0..<500 { if server.hasHeldRequest { break }; try await Task.sleep(nanoseconds: 10_000_000) } + guard server.hasHeldRequest else { server.release(); await request.value; XCTFail("Email request never reached the server"); return } + await replace(auth) + let baseline = server.requests.count + server.release(); await request.value + XCTAssertEqual(server.requests.count, baseline, "An old acknowledgement must not issue a new-owner /me") + XCTAssertNil(old.notice); XCTAssertTrue(auth.accountForm(.emailVerification).canResend) + XCTAssertEqual(auth.stage, .signedIn); XCTAssertEqual(auth.selectedID, 8) + let pair = await auth.service.refresh.current(); XCTAssertEqual(pair?.access, "new-access") + await auth.invalidateSession(message: "Test finished.") + } + func testEmailOwnerSurvivesTokenRotationWithoutMakerspaceHeader() async throws { + let server = EmailOwnerServer(); server.rejectOldEmail = true + let auth = try make(server); await auth.restore(); await auth.select(7) + let form = auth.accountForm(.emailVerification); form.code = "123456" + await form.confirmEmail() + XCTAssertEqual(form.notice, "Email confirmed."); XCTAssertNil(form.error) + await form.resendEmail(); XCTAssertNil(form.error); XCTAssertFalse(form.canResend) + let calls = server.requests + XCTAssertEqual(calls.filter { $0.path == "/api/v1/auth/device/refresh" }.count, 1) + let email = calls.filter { $0.path == EmailOwnerServer.confirm || $0.path == EmailOwnerServer.resend } + XCTAssertEqual(email.count, 3); XCTAssertTrue(email.allSatisfy { $0.makerspace == nil }) + XCTAssertEqual(email.map(\.authorization), ["Bearer old-access", "Bearer rotated-access", "Bearer rotated-access"]) + XCTAssertEqual(auth.stage, .signedIn); XCTAssertEqual(auth.selectedID, 7) + await auth.invalidateSession(message: "Test finished.") + } + func testRefusedRefreshClearsCurrentEmailOwnerAndPrivateState() async throws { + for confirm in [true, false] { + let server = EmailOwnerServer(); server.rejectOldEmail = true; server.rejectRefresh = true + let auth = try make(server); await auth.restore(); await auth.select(7) + var cleared = false; auth.registerPrivateHolder(UUID()) { cleared = true } + let form = auth.accountForm(.emailVerification); form.code = "123456" + if confirm { await form.confirmEmail() } else { await form.resendEmail() } + XCTAssertTrue(cleared); XCTAssertEqual(auth.stage, .signIn); XCTAssertNil(auth.user); XCTAssertNil(auth.selectedID) + let pair = await auth.service.refresh.current(); XCTAssertNil(pair) + XCTAssertEqual(auth.error, "Session revoked."); XCTAssertNil(form.notice) + XCTAssertEqual(server.requests.filter { $0.path == "/api/v1/auth/device/refresh" }.count, 1) + } + } + func testValidationThrottleAndOfflineErrorsKeepVerifiedEmailOwner() async throws { + for failure in [EmailOwnerServer.Failure.validation, .throttle, .offline] { + let server = EmailOwnerServer(); server.emailFailure = failure + let auth = try make(server); await auth.restore(); await auth.select(7) + var cleared = false; auth.registerPrivateHolder(UUID()) { cleared = true } + let form = auth.accountForm(.emailVerification); form.code = "123456" + await form.confirmEmail() + XCTAssertFalse(cleared); XCTAssertEqual(auth.stage, .signedIn); XCTAssertNotNil(auth.user); XCTAssertEqual(auth.selectedID, 7) + XCTAssertNotNil(form.error); XCTAssertNil(form.notice) + let pair = await auth.service.refresh.current(); XCTAssertEqual(pair?.access, "old-access") + if failure == .throttle { XCTAssertFalse(form.canResend) } + XCTAssertFalse(server.requests.contains { $0.path == "/api/v1/auth/device/refresh" }) + await auth.invalidateSession(message: "Test finished.") + } + } +} + +private final class EmailOwnerServer: @unchecked Sendable { + static let confirm = "/api/v1/auth/email-verification/confirm" + static let resend = "/api/v1/auth/email-verification/resend" + struct Request { let path: String; let authorization: String?; let makerspace: String? } + enum Failure: Equatable { case validation, throttle, offline } + private let lock = NSLock() + private var records: [Request] = [] + private var held: EmailOwnerProtocol? + private var heldPath: String? + private var rejectsOldEmail = false + private var rejectsRefresh = false + private var failure: Failure? + var holdPath: String? { + get { lock.lock(); defer { lock.unlock() }; return heldPath } + set { lock.lock(); heldPath = newValue; lock.unlock() } + } + var rejectOldEmail: Bool { + get { lock.lock(); defer { lock.unlock() }; return rejectsOldEmail } + set { lock.lock(); rejectsOldEmail = newValue; lock.unlock() } + } + var rejectRefresh: Bool { + get { lock.lock(); defer { lock.unlock() }; return rejectsRefresh } + set { lock.lock(); rejectsRefresh = newValue; lock.unlock() } + } + var emailFailure: Failure? { + get { lock.lock(); defer { lock.unlock() }; return failure } + set { lock.lock(); failure = newValue; lock.unlock() } + } + var requests: [Request] { lock.lock(); defer { lock.unlock() }; return records } + var hasHeldRequest: Bool { lock.lock(); defer { lock.unlock() }; return held != nil } + func handle(_ source: EmailOwnerProtocol) { + let request = source.request; let path = request.url!.path + let authorization = request.value(forHTTPHeaderField: "Authorization") + lock.lock() + records.append(.init(path: path, authorization: authorization, makerspace: request.value(forHTTPHeaderField: "X-Makerspace-Id"))) + if path == heldPath { held = source; lock.unlock(); return } + let reject = rejectsOldEmail + let refuseRefresh = rejectsRefresh + let emailFailure = failure + lock.unlock() + if [Self.confirm, Self.resend].contains(path), let emailFailure { + switch emailFailure { + case .validation: source.respond(status: 400, json: #"{"detail":"Invalid verification code."}"#) + case .throttle: source.respond(status: 429, json: #"{"detail":"Try again later."}"#, headers: ["Retry-After": "120"]) + case .offline: source.client?.urlProtocol(source, didFailWithError: URLError(.notConnectedToInternet)) + } + return + } + if reject, [Self.confirm, Self.resend].contains(path), authorization == "Bearer old-access" { + source.respond(status: 401, json: #"{"detail":"Expired."}"#); return + } + if refuseRefresh, path == "/api/v1/auth/device/refresh" { + source.respond(status: 401, json: #"{"detail":"Session revoked."}"#); return + } + let body: String + switch path { + case "/api/v1/config": body = DebugAuthFixtures.config + case "/api/v1/auth/me": body = DebugAuthFixtures.user + case "/api/v1/auth/device/attestation-challenge": body = #"{"challenge":"fixture-challenge","expires_in":120}"# + case "/api/v1/auth/device/login": body = DebugAuthFixtures.tokens.replacingOccurrences(of: "test-access", with: "new-access").replacingOccurrences(of: "test-refresh", with: "new-refresh") + case "/api/v1/auth/device/refresh": body = #"{"access":"rotated-access","refresh":"rotated-refresh"}"# + case Self.confirm, Self.resend: body = #"{"detail":"Accepted."}"# + default: XCTFail("Unexpected email-owner API call: " + path); source.respond(status: 500, json: "{}"); return + } + source.respond(status: 200, json: body) + } + func release() { + lock.lock(); let source = held; held = nil; heldPath = nil; lock.unlock() + source?.respond(status: 200, json: #"{"detail":"Accepted."}"#) + } +} +private final class EmailOwnerProtocol: URLProtocol { + private static let lock = NSLock() + private static var currentServer: EmailOwnerServer? + static var server: EmailOwnerServer? { + get { lock.lock(); defer { lock.unlock() }; return currentServer } + set { lock.lock(); currentServer = newValue; lock.unlock() } + } + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { Self.server?.handle(self) } + override func stopLoading() {} + func respond(status: Int, json: String, headers: [String: String] = [:]) { + var fields = headers; fields["Content-Type"] = "application/json" + let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: fields)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(json.utf8)); client?.urlProtocolDidFinishLoading(self) + } +} +#endif diff --git a/SpaceWorksUITests/AccountFlowTests.swift b/SpaceWorksUITests/AccountFlowTests.swift new file mode 100644 index 0000000..d1d04d8 --- /dev/null +++ b/SpaceWorksUITests/AccountFlowTests.swift @@ -0,0 +1,129 @@ +import XCTest + +final class AccountFlowTests: XCTestCase { + override func setUp() { continueAfterFailure = false; XCUIDevice.shared.orientation = .portrait } + private func tap(_ element: XCUIElement, in app: XCUIApplication) { NativeUIActions.tap(element, in: app) } + private func account(extra: [String] = [], withoutMakerspace: Bool = false) -> XCUIApplication { + let app = XCUIApplication(); app.launchArguments = ["--ui-testing"] + extra; app.launch() + tap(app.buttons["explore-demo"], in: app); tap(app.buttons["Continue as member"], in: app) + let username = app.textFields["demo-username"]; tap(username, in: app); username.typeText("member\n") + app.secureTextFields["demo-password"].typeText("sample\n"); tap(app.buttons["demo-sign-in"], in: app) + if !withoutMakerspace { + tap(app.buttons["demo-space-0"], in: app) + if app.tabBars.buttons["Profile"].exists { tap(app.tabBars.buttons["Profile"], in: app) } + else { + let profile = app.descendants(matching: .any)["nav-Profile"].firstMatch + if !profile.isHittable { + let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'sidebar'")).firstMatch + if sidebar.exists { sidebar.tap() } + } + tap(profile, in: app) + } + } + tap(app.buttons["account-entry"], in: app); return app + } + private func confirmNativeDialog(_ title: String, action label: String, in app: XCUIApplication) { + // Native confirmation popovers own separate title/action scroll regions. + XCTAssertTrue(app.staticTexts[title].waitForExistence(timeout: 5)) + let action = app.buttons[label] + XCTAssertTrue(action.waitForExistence(timeout: 5)) + XCTAssertTrue(action.isHittable) + let frame = action.frame + XCTAssertGreaterThan(frame.width, 0) + XCTAssertGreaterThan(frame.height, 0) + XCTAssertTrue(app.frame.insetBy(dx: 2, dy: 2).contains(frame)) + attach("Native account confirmation") + action.tap() + } + private func attach(_ name: String) { + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); attachment.name = name; attachment.lifetime = .keepAlways; add(attachment) + } + func testPhoneVerificationThenCurrentSessionRevocationRequiresSignIn() { + let app = account(); tap(app.buttons["account-phone"], in: app) + let phone = app.textFields["account-phone-number"]; tap(phone, in: app); phone.typeText("9876543210") + tap(app.toolbars.buttons["Done"], in: app); tap(app.buttons["account-phone-send"], in: app) + let code = app.textFields["account-phone-code"]; tap(code, in: app); code.typeText("123456") + tap(app.toolbars.buttons["Done"], in: app); tap(app.buttons["account-phone-verify"], in: app) + XCTAssertTrue(app.staticTexts["+919876543210"].waitForExistence(timeout: 10)); attach("Account verified phone sample") + tap(app.navigationBars.buttons.element(boundBy: 0), in: app) + tap(app.buttons["account-sessions"], in: app); tap(app.buttons["account-revoke-current-session"], in: app) + confirmNativeDialog("Sign out this session?", action: "Sign out", in: app) + if app.buttons["account-security-continue"].waitForExistence(timeout: 2) { tap(app.buttons["account-security-continue"], in: app) } + XCTAssertTrue(app.textFields["demo-username"].waitForExistence(timeout: 10)) + } + func testRecoveryCodesAreAcknowledgedBeforeReturningToSignIn() { + let app = account(); tap(app.buttons["account-security"], in: app) + let password = app.secureTextFields["account-current-password"]; tap(password, in: app); password.typeText("DemoPass!123\n") + tap(app.buttons["account-regenerate-recovery"], in: app) + confirmNativeDialog("Replace your recovery codes?", action: "Continue", in: app) + XCTAssertTrue(app.staticTexts["Save your recovery codes"].waitForExistence(timeout: 10)) + let next = app.buttons["account-security-continue"]; XCTAssertFalse(next.isEnabled) + tap(app.switches["account-recovery-saved"], in: app); tap(next, in: app) + XCTAssertTrue(app.textFields["demo-username"].waitForExistence(timeout: 10)) + } + func testExportRequestStatusAndNativeArchivePresentation() { + let app = account(); tap(app.buttons["account-exports"], in: app) + tap(app.buttons["account-request-export"], in: app) + XCTAssertTrue(app.buttons["account-check-export"].waitForExistence(timeout: 10)) + tap(app.buttons["account-check-export"], in: app); tap(app.buttons["account-download-export"], in: app) + XCTAssertTrue(app.navigationBars["Data export"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.buttons["account-share-export"].exists); attach("Account native demo archive") + tap(app.buttons["Done"], in: app); XCTAssertTrue(app.buttons["account-download-export"].waitForExistence(timeout: 5)) + } + func testLargestTextLandscapeExportCanReachNativeShare() { + let app = account(extra: ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]) + tap(app.buttons["account-exports"], in: app); tap(app.buttons["account-request-export"], in: app) + tap(app.buttons["account-check-export"], in: app); tap(app.buttons["account-download-export"], in: app) + XCTAssertTrue(app.navigationBars["Data export"].waitForExistence(timeout: 10)) + XCUIDevice.shared.orientation = .landscapeLeft + XCTAssertGreaterThan(app.frame.width, app.frame.height) + // NativeUIActions requires a real visible/hittable action after bounded scrolling. + tap(app.buttons["account-share-export"], in: app) + let systemSave = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Save to Files")).firstMatch + XCTAssertTrue(systemSave.waitForExistence(timeout: 5), "The actual native share sheet must expose a system file action.") + attach("Native archive share largest text landscape") + } + func testGlobalPasswordAndDevicesWorkBeforeJoiningAMakerspace() { + let app = account(extra: ["--demo-no-memberships"], withoutMakerspace: true) + XCTAssertFalse(app.buttons["account-sessions"].exists); XCTAssertFalse(app.buttons["account-membership"].exists) + tap(app.buttons["account-password"], in: app) + for (identifier, value) in [("account-password-current", "DemoPass!123"), ("account-password-new", "NewDemoPass!456"), ("account-password-confirm", "NewDemoPass!456")] { + let field = app.secureTextFields[identifier]; tap(field, in: app); field.typeText(value + "\n") + } + tap(app.buttons["account-password-update"], in: app) + XCTAssertTrue(app.staticTexts["Password change preview complete. No password was changed."].waitForExistence(timeout: 10)) + attach("Global account password without membership") + tap(app.navigationBars.buttons.element(boundBy: 0), in: app); tap(app.buttons["account-devices"], in: app) + XCTAssertTrue(app.buttons["account-revoke-device-83000000-0000-4000-8000-000000000001"].waitForExistence(timeout: 10)) + attach("Global devices without membership") + } + func testDeletionPreviewRequiresIdentityAndExactTypedConfirmation() { + let app = account(); tap(app.buttons["account-delete-preview"], in: app) + tap(app.buttons["deletion-scenario"], in: app); tap(app.buttons["Ready to delete"], in: app) + tap(app.buttons["deletion-check"], in: app) + let password = app.secureTextFields["deletion-password"]; tap(password, in: app); password.typeText("DemoPass!123\n") + tap(app.buttons["deletion-verify"], in: app) + XCTAssertFalse(app.buttons["deletion-submit"].isEnabled) + let confirmation = app.textFields["deletion-confirmation"]; tap(confirmation, in: app); confirmation.typeText("DELETE\n") + XCTAssertTrue(app.buttons["deletion-submit"].isEnabled); attach("Whole account deletion demo exact confirmation") + tap(app.buttons["deletion-submit"], in: app) + XCTAssertTrue(app.buttons["Continue as member"].waitForExistence(timeout: 10)) + } + func testDeletionBlockerOpensCurrentMakerspacePaymentFlow() { + let app = account(); tap(app.buttons["account-delete-preview"], in: app); tap(app.buttons["deletion-check"], in: app) + XCTAssertTrue(app.staticTexts["Resolve these before deleting"].waitForExistence(timeout: 10)) + tap(app.buttons["Open Payments"].firstMatch, in: app) + XCTAssertTrue(app.navigationBars["Payments"].waitForExistence(timeout: 10)); attach("Deletion payment blocker opens actual sample payments") + } + func testLeavingMakerspaceReturnsToRemainingAuthorizedMemberships() { + let app = account(); tap(app.buttons["account-membership"], in: app) + let reason = app.textViews["account-membership-reason"].exists ? app.textViews["account-membership-reason"] : app.textFields["account-membership-reason"] + tap(reason, in: app); reason.typeText("Moving away") + if app.toolbars.buttons["Done"].exists { tap(app.toolbars.buttons["Done"], in: app) } + tap(app.buttons["account-leave-makerspace"], in: app) + confirmNativeDialog("Leave this makerspace?", action: "Confirm leave", in: app) + XCTAssertTrue(app.buttons["demo-space-1"].waitForExistence(timeout: 10)); XCTAssertFalse(app.buttons["demo-space-0"].exists) + attach("Confirmed membership leave keeps other makerspace") + } + +} diff --git a/SpaceWorksUITests/SecurityFlowTests.swift b/SpaceWorksUITests/SecurityFlowTests.swift new file mode 100644 index 0000000..e2b0202 --- /dev/null +++ b/SpaceWorksUITests/SecurityFlowTests.swift @@ -0,0 +1,39 @@ +import XCTest + +final class SecurityFlowTests: XCTestCase { + override func setUp() { continueAfterFailure = false; XCUIDevice.shared.orientation = .portrait } + func testIsolatedEmailVerificationPreviewContinuesToMakerspaceSelection() { + let app = XCUIApplication(); app.launchArguments = ["--ui-testing"]; app.launch() + NativeUIActions.tap(app.buttons["explore-demo"], in: app); NativeUIActions.tap(app.buttons["Continue as member"], in: app) + let username = app.textFields["demo-username"]; NativeUIActions.tap(username, in: app); username.typeText("unverified\n") + app.secureTextFields["demo-password"].typeText("sample\n"); NativeUIActions.tap(app.buttons["demo-sign-in"], in: app) + XCTAssertTrue(app.staticTexts["Confirm your email"].waitForExistence(timeout: 10)) + let guidance = app.staticTexts["No message is sent in this demo. Use the sample verification code 123456."] + for _ in 0..<3 where !guidance.isHittable { app.scrollViews.firstMatch.swipeUp(velocity: .slow) } + XCTAssertTrue(guidance.isHittable, "Sample instructions must be visible before using the code") + NativeUIActions.tap(app.buttons["Send another code"], in: app) + XCTAssertTrue(app.staticTexts["Demo code ready. Use 123456. No email was sent."].waitForExistence(timeout: 5)) + let code = app.textFields["account-code"]; NativeUIActions.tap(code, in: app); code.typeText("123456") + NativeUIActions.tap(app.buttons["Confirm email"], in: app) + XCTAssertTrue(app.staticTexts["Email confirmed."].waitForExistence(timeout: 5)) + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); attachment.name = "Isolated email verification preview"; attachment.lifetime = .keepAlways; add(attachment) + NativeUIActions.tap(app.buttons["Done"], in: app) + XCTAssertTrue(app.buttons["demo-space-0"].waitForExistence(timeout: 10)) + } + func testRequiredPasswordUpdateReturnsToFreshSignIn() { + let app = XCUIApplication(); app.launchArguments = ["--ui-testing", "--temporary-password"]; app.launch() + let server = app.textFields["server-url"]; NativeUIActions.tap(server, in: app); server.typeText("https://ui-test.spaceworks.invalid\n") + XCTAssertTrue(app.textFields["username"].waitForExistence(timeout: 10)) + NativeUIActions.tap(app.textFields["username"], in: app); app.textFields["username"].typeText("maker\n") + app.secureTextFields["password"].typeText("temporary\n"); NativeUIActions.tap(app.buttons["sign-in"], in: app) + XCTAssertTrue(app.secureTextFields["current-password"].waitForExistence(timeout: 10)) + for (identifier, value) in [("current-password", "temporary"), ("new-password", "ReplacementPass!123"), ("confirm-password", "ReplacementPass!123")] { + let field = app.secureTextFields[identifier]; NativeUIActions.tap(field, in: app); field.typeText(value + "\n") + } + NativeUIActions.tap(app.buttons["Update password"], in: app) + XCTAssertTrue(app.textFields["username"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["Password updated. Sign in again."].exists) + XCTAssertFalse(app.tabBars.firstMatch.exists) + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); attachment.name = "Required password update requires fresh native authentication"; attachment.lifetime = .keepAlways; add(attachment) + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index acc93ac..3f551e3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,7 +1,69 @@ # Phase notes +## A7 Account and localization — implementation checkpoint (2026-10-17) + +Provide global Account and security before joining a makerspace: authenticator status, phone linking, +linked providers, password change and native Devices. Separately gate member Sessions, membership +cancellation/leave and data exports with the actual selected membership and bootstrap capabilities. +Never invent a makerspace0 or infer a current device from list order. A confirmed leave removes local +private scope before refetch, and failed notification cleanup blocks leaving until recoverable. + +Account repositories retain the accepted credential installation across requests, retries and +wrong-password identity probes. A retired account cannot issue a request or sign out its replacement. +Manual password changes validate identity after the server acknowledgement; the required temporary +password gate wipes local protected state and returns to fresh sign-in. Security changes that end +sessions clear native credentials before showing transient recovery-code acknowledgement. Existing +live management routes are used where supported; native MFA setup/provider linking that the current +server refuses remains explicitly unavailable, with full isolated Debug journeys. + +The Debug-only whole-account deletion journey explains all-makerspace scope, lists every blocker with +fix destinations, requires fresh sample password/OTP proof and exact DELETE confirmation, and handles +expired proof/conflicting new obligations. It only resets preview memory. The immutable schema has no +account-deletion route, so production deletion remains an explicit backend/release dependency. + +Prepare an English app catalog and processed API fallback catalog, localize app-owned labels/errors +at their producers, retain raw server/user text and wire values, and bundle English camera purpose +copy. Existing privacy shielding protects credentials, authenticator secrets and recovery codes +across background snapshots. Additional languages and translation layout are not claimed validated. + +Source adds57 native state/API cases and8 account/security UI journeys, pending Mac execution at +this checkpoint. Parity: Account and security / Devices / Sessions / Change password / Password changed. +/ Password updated. Sign in again. / Leave makerspace / Cancel renewal / Export your data / Delete account. + + +The Debug username unverified opens an isolated email-confirmation preview with sample code 123456, rejected-code handling, shared resend cooldown and safe reset; no message is sent or server authority inferred. Export archive controls scroll at accessibility sizes, with a separate largest-text landscape native Share journey. These add two state cases and two UI journeys; execution remains pending. + +Bind authenticated email verification and resend forms to the accepted installation, account and +identity generation. Retired forms cannot first-send under a replacement login or refresh its +identity after a delayed acknowledgement. Email cooldowns belong to the account; an old resend +cannot mutate a replacement form's deadline. Six URLProtocol regressions cover first-send, +delayed confirm/resend, valid same-installation token rotation, current-owner refused refresh and +validation/throttle/offline errors with a valid session; +native execution is pending. Refused refresh wipes protected identity through the central owner; +400/429 and offline errors with valid credentials keep the session. State checks ownership again +before committing acknowledgement, cooldown or error copy. + +Native session, recovery-code and membership confirmations use guarded dialog-specific button +activation in their UI journeys: assert the visible fixed title, action presence, native hittability +and full window frame before tapping. This carries forward the proven A5 popover test correction; +shared app scrolling and all post-action assertions remain unchanged. Native A7 execution is pending. + +A superseded account cooldown cannot clear the newer server deadline, even when its sleep finishes +normally during cancellation. The injected timer dependency keeps the real production sleep and +3600-second chunk cap; one controlled two-request URLProtocol regression holds both security/provider +reads before429, replaces the timer and checks that late completion cannot reopen requests. Native +execution remains pending. + +The existing largest-text landscape archive journey waits for the native share sheet’s Save to Files +action before capture, rather than treating a tap as proof of system presentation. It does not save +externally, add a case or extend existing readiness limits. Native proof remains pending. + ## A6 Staff — implementation checkpoint (2026-10-17) +Completion evidence: [`53843cb` CI39014792272](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/39014792272) passed 219 native state/API tests and 24 native UI journeys on both phone and tablet, 19 Python generation/fixture checks, and the unsigned physical-device Release build. These are contract-fixture/compiler results; real device/provider/backend validation remains a release gate. + +Independent full-OS review accepted phone and tablet handover evidence/kit count, the native Record handover confirmation and the recorded request #602, plus attendance person/status confirmation and the recorded notice. The complete large-text Submit request control and receipt were visible on both sizes. On the phone the bounded native scroll alternate actually ran after an unchanged offscreen frame and moved the target from y1727.29 to y1150.96; the later activation required the full button at y716.62–842. The individual coordinate-pan no-op mechanism remains unproven; no production gesture or framework cause is claimed. All earlier member/payment journeys, including actual receipt-page OCR, remain enabled and passed. + Initial native evidence: [`eaa400d` CI39006807733](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/39006807733) passed all 215 native state/API cases and the 22 earlier UI journeys on both phone and tablet. The two new staff journeys hung while opening their destinations, so this phase was not accepted and the unsigned physical-device Release step was skipped. Destination construction registered weak child-cleanup callbacks on an observable array. That bookkeeping now avoids view observation; a focused actual-child construction regression checks that registration does not invalidate the parent view while scope invalidation still clears both children. The corrected 219-native/24-UI complete gate remains pending. The next checkpoint, [`0c5c38b` CI39009445400](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/39009445400), passed all 219 native cases and the 22 earlier UI journeys on both devices. Both staff destinations now opened without the previous observation hang. The new journeys found later accessibility/test issues: a parent card identifier replaced the attendance button identifier, and the handover test selected no kit components because it queried a shortened label. Remove the unused parent identifier; explicitly select the one sample component and verify its stepper value and disabled-to-enabled confirmation. Wait for departing Profile/scanner content before querying the next screen. Shared scrolling, native confirmations and all post-mutation assertions remain intact. The complete 219-native/24-UI/Release gate remains pending; neither failing checkpoint is phase acceptance. diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md new file mode 100644 index 0000000..adfd42e --- /dev/null +++ b/docs/LOCALIZATION.md @@ -0,0 +1,13 @@ +# Localization readiness + +English is the current development language. No additional language is claimed as translated. + +App-owned UI copy lives in `SpaceWorks/Resources/Localizable.xcstrings`. Use literal SwiftUI labels, `LocalizedStringKey` for wrappers that only accept application labels, and `String(localized:)` before storing locally authored state messages. Keep user names, makerspace names, product descriptions, API details, identifiers and URLs verbatim; never reinterpret a server message as a localization key. + +Run `python scripts/export_localization_catalog.py` after changing plain local labels. This seeds English keys and preserves existing translations. Xcode's string extraction handles interpolation and plural forms; use its localization editor/export flow to review format arguments and add languages. The seed script deliberately does not infer translations or parse API/fixture data. + +The API package uses its own `Resources/en.lproj/Localizable.strings` and `Bundle.module` for application fallback errors. Detailed errors received from the server remain unchanged. Native purpose copy is in `SpaceWorks/Resources/en.lproj/InfoPlist.strings`; keep its English camera purpose aligned with the generated Info.plist. + +Before adding a language, translate both app and API resources, review plural/number/date formatting, and run the phone/tablet matrix with the new language, largest Dynamic Type, and right-to-left layout where applicable. A translated text resource alone is not UI validation. + +References: [Apple: preparing text for translation](https://developer.apple.com/documentation/xcode/preparing-your-apps-text-for-translation), [Apple: localizing package resources](https://developer.apple.com/documentation/xcode/localizing-package-resources). diff --git a/project.yml b/project.yml index 772d762..e7e466e 100644 --- a/project.yml +++ b/project.yml @@ -1,5 +1,6 @@ name: SpaceWorks options: + developmentLanguage: en minimumXcodeGenVersion: 2.42.0 deploymentTarget: iOS: '17.0' @@ -9,6 +10,7 @@ configFiles: settings: base: SWIFT_VERSION: '5.0' + SWIFT_EMIT_LOC_STRINGS: YES CODE_SIGN_STYLE: Automatic CURRENT_PROJECT_VERSION: 1 MARKETING_VERSION: 0.1.0 diff --git a/scripts/build_account_fixtures.py b/scripts/build_account_fixtures.py new file mode 100644 index 0000000..1f0445d --- /dev/null +++ b/scripts/build_account_fixtures.py @@ -0,0 +1,38 @@ +"""Build synthetic account examples from the same pinned schema as the generated client.""" +import base64 +import io +import json +import zipfile +from build_staff_fixtures import ROOT, model + + +def build(): + current = "83000000-0000-4000-8000-000000000001" + other = "83000000-0000-4000-8000-000000000002" + fixtures = { + "sessions": model("AccountSessionPage", count=2, results=[ + model("AccountSession", id="device:" + current, kind="device", current=True), + model("AccountSession", id="web:sample-other-session", kind="web", current=False), + ]), + "grants": [model("DeviceGrant", id=current, platform="apple", app_id="org.spaceworks.app", environment="development", status="active"), + model("DeviceGrant", id=other, platform="android", app_id="org.spaceworks.app", environment="development", status="active")], + "membership": model("MembershipAccount", status="active"), + "mfa": model("MFAStatus", available=True, enabled=True, required=False), + "challenge": model("MFAChallengeResponse", mfa_required=True, enrollment_required=True, challenge="demo-account-challenge", expires_in=300), + "recovery": model("MFARecovery", recovery_codes=["demo-recovery-01", "demo-recovery-02", "demo-recovery-03"]), + "provider": model("SocialIdentity", provider="google"), + "export": model("SubjectExport", id="84000000-0000-4000-8000-000000000001", status="available", failure_code="", failure_detail="", manifest={"demo": True}, expires_at="2099-01-01T00:00:00Z"), + } + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_STORED) as archive: + info = zipfile.ZipInfo("README.txt", date_time=(2026, 10, 16, 9, 0, 0)) + archive.writestr(info, "SPACE WORKS DEMO EXPORT\nSample data only. No personal account data is included.\n") + fixtures["archive"] = {"base64": base64.b64encode(buffer.getvalue()).decode("ascii")} + directory = ROOT / "Fixtures/Account" + directory.mkdir(parents=True, exist_ok=True) + for name, value in fixtures.items(): + (directory / (name + ".json")).write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + build() diff --git a/scripts/export_localization_catalog.py b/scripts/export_localization_catalog.py new file mode 100644 index 0000000..9c2dd64 --- /dev/null +++ b/scripts/export_localization_catalog.py @@ -0,0 +1,57 @@ +"""Seed English app-owned literal keys; Xcode extracts interpolated keys at build time. + +Run manually after copy changes. Existing translations are retained. This never scans +API responses, user input, fixtures, generated clients, keys, URLs, or provider IDs. +""" +import argparse +import json +import pathlib +import re + +ROOT = pathlib.Path(__file__).resolve().parents[1] +CATALOG = ROOT / "SpaceWorks/Resources/Localizable.xcstrings" +LITERAL = r'"((?:[^"\\]|\\.)*)"' +CALL = re.compile(r'(?:\b(?:Text|Button|Label|ProgressView|NavigationLink|Toggle|Picker|TextField|SecureField|LocalizedStringKey)|\.(?:navigationTitle|accessibilityLabel))\(\s*' + LITERAL) +OWNED_LABEL = re.compile(r'\b(?:StaffMenuRow|AccountMenuRow)\(\s*title:\s*' + LITERAL) +OWNED_DETAIL = re.compile(r'\bStaffMenuRow\([^\n]*?detail:\s*' + LITERAL) +LOCALIZED = re.compile(r'\bString\(localized:\s*' + LITERAL) + + +def literal_keys(source): + values = set() + for pattern in [CALL, LOCALIZED, OWNED_LABEL, OWNED_DETAIL]: + for match in pattern.finditer(source): + raw = match[1] + if not raw or "\\(" in raw: + continue # Preserve interpolation/plurals for the Swift compiler's extraction. + try: + value = json.loads('"' + raw + '"') + except ValueError: + continue + values.add(value) + return values + + +def update(check=False): + catalog = json.loads(CATALOG.read_text(encoding="utf-8")) if CATALOG.exists() else {"sourceLanguage": "en", "strings": {}, "version": "1.0"} + strings = catalog["strings"] + keys = set() + for source in (ROOT / "SpaceWorks").rglob("*.swift"): + if "Generated" not in source.parts: + keys.update(literal_keys(source.read_text(encoding="utf-8"))) + missing = keys - strings.keys() + if check: + if missing: + raise SystemExit("App-owned English catalog keys missing: " + ", ".join(sorted(missing))) + return + for key in keys: + strings.setdefault(key, {"extractionState": "manual", "localizations": {"en": {"stringUnit": {"state": "translated", "value": key}}}}) + catalog["strings"] = dict(sorted(strings.items())) + CATALOG.parent.mkdir(parents=True, exist_ok=True) + CATALOG.write_text(json.dumps(catalog, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"English app catalog contains {len(strings)} keys; existing translations retained.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(); parser.add_argument("--check", action="store_true") + update(parser.parse_args().check) diff --git a/scripts/generate.py b/scripts/generate.py index 4b759b6..3158370 100644 --- a/scripts/generate.py +++ b/scripts/generate.py @@ -119,6 +119,10 @@ def generate(raster=True): staff_module = importlib.util.module_from_spec(staff_spec) staff_spec.loader.exec_module(staff_module) staff_module.generate_staff_fixtures(ROOT) + account_spec = importlib.util.spec_from_file_location("spaceworks_account_fixtures", ROOT / "scripts/generate_account_fixtures.py") + account_module = importlib.util.module_from_spec(account_spec) + account_spec.loader.exec_module(account_module) + account_module.generate_account_fixtures(ROOT) wordmark = assets / "Wordmark.imageset" wordmark.mkdir(parents=True, exist_ok=True) for name, master in [("light.png", "lockup-horizontal-one-colour-ink.png"), ("dark.png", "lockup-horizontal-reversed.png")]: diff --git a/scripts/generate_account_fixtures.py b/scripts/generate_account_fixtures.py new file mode 100644 index 0000000..4e0a56c --- /dev/null +++ b/scripts/generate_account_fixtures.py @@ -0,0 +1,16 @@ +"""Synthetic account data is compiled only in Debug and never bundled as raw resources.""" +import pathlib + + +def generate_account_fixtures(root): + root = pathlib.Path(root) + lines = ["// Generated synthetic A7 fixtures.", "#if DEBUG", "enum DebugAccountFixtures {"] + for source in sorted((root / "Fixtures/Account").glob("*.json")): + value = source.read_text(encoding="utf-8").strip() + if not source.stem.isidentifier() or '"""#' in value or "\\#(" in value: + raise ValueError("Unsafe raw Swift literal") + lines.append(f' static let {source.stem} = #"""\n{value}\n"""#') + lines.extend(["}", "#endif", ""]) + destination = root / "SpaceWorks/Generated/DebugAccountFixtures.swift" + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text("\n".join(lines), encoding="utf-8") diff --git a/scripts/tests/test_account_fixtures.py b/scripts/tests/test_account_fixtures.py new file mode 100644 index 0000000..8440d55 --- /dev/null +++ b/scripts/tests/test_account_fixtures.py @@ -0,0 +1,21 @@ +import base64 +import io +import json +import zipfile +import test_staff_fixtures + + +class AccountFixturesTests(test_staff_fixtures.StaffFixturesTests): + directory = "Account" + models = { + "sessions": "AccountSessionPage", "membership": "MembershipAccount", "mfa": "MFAStatus", + "challenge": "MFAChallengeResponse", "recovery": "MFARecovery", "provider": "SocialIdentity", "export": "SubjectExport", + "grants": {"type": "array", "items": {"$ref": "#/components/schemas/DeviceGrant"}}, + } + + def test_sample_archive_is_a_valid_zip_containing_only_explicit_demo_data(self): + fixture = test_staff_fixtures.ROOT / "Fixtures/Account/archive.json" + data = base64.b64decode(json.loads(fixture.read_text(encoding="utf-8"))["base64"]) + with zipfile.ZipFile(io.BytesIO(data)) as archive: + self.assertEqual(archive.namelist(), ["README.txt"]) + self.assertIn("SPACE WORKS DEMO EXPORT", archive.read("README.txt").decode("utf-8")) From 9e619b7ce4ad34115933ae69c2b12024f2a9eff1 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:13:41 +0530 Subject: [PATCH 41/98] feat(a8): add usage guide and release handoff Co-Authored-By: Codex --- .github/workflows/ios.yml | 8 +- README.md | 17 +- SpaceWorks/AccountAccessView.swift | 2 +- SpaceWorks/AccountViews.swift | 3 +- SpaceWorks/AppGuideView.swift | 78 ++++ SpaceWorks/AuthView.swift | 13 +- SpaceWorks/CooldownCopy.swift | 23 + SpaceWorks/DemoGuidance.swift | 20 + SpaceWorks/DemoMode.swift | 19 +- SpaceWorks/MemberActionViews.swift | 2 +- SpaceWorks/MemberBookingsView.swift | 39 ++ SpaceWorks/MemberNavigation.swift | 6 +- SpaceWorks/MemberRoutes.swift | 1 + SpaceWorks/MemberState.swift | 11 +- SpaceWorks/MemberViews.swift | 8 +- SpaceWorks/Resources/Localizable.xcstrings | 429 ++++++++++++++++++ SpaceWorks/Resources/PrivacyInfo.xcprivacy | 17 + SpaceWorks/RootView.swift | 22 +- SpaceWorks/SpaceWorksApp.swift | 4 +- SpaceWorks/StaffState.swift | 13 +- SpaceWorksTests/CooldownCopyTests.swift | 26 ++ SpaceWorksTests/DemoGuidanceTests.swift | 38 ++ SpaceWorksTests/GuideNavigationTests.swift | 34 ++ SpaceWorksTests/StaffCooldownTests.swift | 46 ++ SpaceWorksUITests/AccountFlowTests.swift | 2 +- SpaceWorksUITests/GuideFlowTests.swift | 73 +++ SpaceWorksUITests/MainFlowTests.swift | 8 +- .../MemberActionsFlowTests.swift | 35 +- SpaceWorksUITests/MemberFlowTests.swift | 2 +- SpaceWorksUITests/NativeTestLaunch.swift | 40 ++ .../NotificationsFlowTests.swift | 2 +- SpaceWorksUITests/PaymentsFlowTests.swift | 2 +- SpaceWorksUITests/SecurityFlowTests.swift | 4 +- SpaceWorksUITests/StaffFlowTests.swift | 2 +- docs/CHANGELOG.md | 42 ++ docs/RELEASE-READINESS.md | 147 ++++++ docs/STORE-LISTING.md | 110 +++++ docs/WHITE-LABEL.md | 32 +- project.yml | 3 + scripts/audit_bundle.py | 67 +++ scripts/brand_artwork.py | 46 ++ scripts/brand_links.py | 53 +++ scripts/device-matrix.py | 5 +- scripts/generate.py | 48 +- scripts/tests/test_audit_bundle.py | 70 +++ scripts/tests/test_brand_artwork.py | 46 ++ scripts/tests/test_brand_links.py | 60 +++ scripts/tests/test_generation.py | 79 +++- scripts/tests/test_mobile_capabilities.py | 6 +- scripts/tests/test_motion.py | 12 +- 50 files changed, 1790 insertions(+), 85 deletions(-) create mode 100644 SpaceWorks/AppGuideView.swift create mode 100644 SpaceWorks/CooldownCopy.swift create mode 100644 SpaceWorks/DemoGuidance.swift create mode 100644 SpaceWorks/MemberBookingsView.swift create mode 100644 SpaceWorks/Resources/PrivacyInfo.xcprivacy create mode 100644 SpaceWorksTests/CooldownCopyTests.swift create mode 100644 SpaceWorksTests/DemoGuidanceTests.swift create mode 100644 SpaceWorksTests/GuideNavigationTests.swift create mode 100644 SpaceWorksTests/StaffCooldownTests.swift create mode 100644 SpaceWorksUITests/GuideFlowTests.swift create mode 100644 SpaceWorksUITests/NativeTestLaunch.swift create mode 100644 docs/RELEASE-READINESS.md create mode 100644 docs/STORE-LISTING.md create mode 100644 scripts/audit_bundle.py create mode 100644 scripts/brand_artwork.py create mode 100644 scripts/brand_links.py create mode 100644 scripts/tests/test_audit_bundle.py create mode 100644 scripts/tests/test_brand_artwork.py create mode 100644 scripts/tests/test_brand_links.py diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 79f933f..8d3abc4 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -17,7 +17,7 @@ jobs: name: Build and test (${{ matrix.device }}) strategy: fail-fast: false - max-parallel: 2 + max-parallel: 6 matrix: device: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["compact","phone","large","tablet","tablet-landscape","large-tablet"]' || '["phone","tablet"]') }} steps: @@ -72,7 +72,7 @@ jobs: - name: Compile and test on simulator run: | set -o pipefail - xcodebuild test -project SpaceWorks.xcodeproj -scheme SpaceWorks -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -resultBundlePath TestResults.xcresult -skipPackagePluginValidation ONLY_ACTIVE_ARCH=YES CODE_SIGN_STYLE=Manual CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= | tee build.log + xcodebuild test -project SpaceWorks.xcodeproj -scheme SpaceWorks -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -resultBundlePath TestResults.xcresult -skipPackagePluginValidation "SPACEWORKS_TEST_PROFILE=$DEVICE_PROFILE" ONLY_ACTIVE_ARCH=YES CODE_SIGN_STYLE=Manual CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= | tee build.log - name: Export UI screenshots if: always() run: | @@ -84,6 +84,9 @@ jobs: run: | set -o pipefail xcodebuild build -project SpaceWorks.xcodeproj -scheme SpaceWorks -configuration Release -destination 'generic/platform=iOS' -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -skipPackagePluginValidation CODE_SIGNING_ALLOWED=NO | tee release-build.log + - name: Audit unsigned Release bundle resources + if: success() && matrix.device == 'phone' + run: python3 scripts/audit_bundle.py DerivedData/Build/Products/Release-iphoneos/SpaceWorks.app - name: Archive validation evidence if: always() uses: actions/upload-artifact@v4 @@ -95,6 +98,7 @@ jobs: build.log release-build.log validation-device.json + validation-bundle.json DerivedData/Build/Intermediates.noindex/BuildToolPluginIntermediates/**/GeneratedSources/*.swift SpaceWorks.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved retention-days: 7 diff --git a/README.md b/README.md index 66a0311..4bdd2e5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Space Works for iOS -iOS 17+ native SwiftUI app. Start with [the PRD](docs/PRD.md); current scope is **A0 Base**. Each later phase waits until its predecessor is merged. +iOS 17+ native SwiftUI app for iPhone and iPad. [The PRD](docs/PRD.md) defines the flows; the owner's extensions in [AGENTS.md](AGENTS.md) add tablets, shared onboarding/auth UX and completion of the remaining phases on `dev`. [CHANGELOG.md](docs/CHANGELOG.md) records actual phase implementation, tests and parity. Only recorded passing phases count as complete. ## Development workflow @@ -8,17 +8,24 @@ Use `dev` for ongoing changes and push commits to `origin/dev`. After implementa ## Build and test +Use Xcode 26.2 with the iOS 26 SDKs, matching CI's pinned toolchain. Select that installation +through `xcode-select` or `DEVELOPER_DIR`; Xcode 16.4 cannot compile the pinned Stripe SDK. + ```sh git submodule update --init --recursive brew install xcodegen librsvg python3 scripts/generate.py python3 -m unittest discover -s scripts/tests -v xcodegen generate -xcodebuild test -project SpaceWorks.xcodeproj -scheme SpaceWorks -destination 'platform=iOS Simulator,name=iPhone 16' -skipPackagePluginValidation CODE_SIGNING_ALLOWED=NO +python3 scripts/lint.py ``` -Choose an installed iPhone simulator (`xcrun simctl list devices available`). CI selects one automatically and retains the test results and screenshots. On a host without a Mac, CI is the Swift compiler; passing Python checks alone does not complete the phase. +Follow [RELEASE-READINESS.md](docs/RELEASE-READINESS.md) for simulator selection and the exact test command, including the ad-hoc simulator Keychain signing identity. CI tests representative phone/tablet profiles, retains screenshots and compiles an unsigned physical-device Release build. Manually dispatch the workflow for the complete configured device matrix. Inspect recorded hardware/runtime names; the matrix does not represent every Apple device. Windows cannot compile SwiftUI; passing Python checks alone does not establish an iOS build or test pass. + +`project.yml` is the project source. The SPM build plugin generates client/models from a tested build projection of `api/openapi.json`; generated Swift and Xcode projects are never committed. The authoritative schema stays byte-identical. `scripts/regenerate-api.sh` builds the API package independently. Operation filters expand with implemented phases; never hand-write API payload models. [Contract provenance](api/provenance.json) records the supplied schema hash and source revision. + +Choose a server on launch or lock one HTTPS origin in `branding/brand.json`. Debug builds allow documented local Docker origins and an explicitly labelled, isolated in-memory demo. Demo authentication, notifications and checkout previews do not establish a live session, push delivery or payment. Release builds omit demo entry points. [LOCAL-DOCKER-VALIDATION.md](docs/LOCAL-DOCKER-VALIDATION.md) separates local API evidence from fixtures. -`project.yml` is the project source. The SPM build plugin generates client/models from `api/openapi.json` into build output; generated Swift and Xcode projects are never committed. The authoritative source stays byte-identical. A tested build projection selects the refresh operation's documented JSON representation because Apple's generator turns a shared JSON/multipart component into an unencodable multipart enum. No API fields or schemas change. `scripts/regenerate-api.sh` builds the API package independently. A0 filters health and refresh operations, with dependencies, using Apple's generator filter. Add operations when later phases need them, never hand-write API payload models. [Contract provenance](api/provenance.json) records the supplied schema hash and source revision. +The backend and owner provisioning are deferred. Unsupported native contracts remain unavailable; see [backend dependencies](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/issues/3). Real attestation, providers, push, checkout, account deletion and store distribution require the deployment inputs and verification in the release guide. -Brand configuration and signing: [WHITE-LABEL.md](docs/WHITE-LABEL.md). Current validation and parity: [CHANGELOG.md](docs/CHANGELOG.md). Platform fonts remain in use pending Gilroy app-embedding clearance. +Brand configuration and signing: [WHITE-LABEL.md](docs/WHITE-LABEL.md). Space Works uses the pinned Design semantic colors, static logo masters, native system text and licensed Unbounded accent font. Gilroy is not embedded without app-embedding permission. Never commit signing material, provider secrets or test credentials. diff --git a/SpaceWorks/AccountAccessView.swift b/SpaceWorks/AccountAccessView.swift index 778c35c..92a55d5 100644 --- a/SpaceWorks/AccountAccessView.swift +++ b/SpaceWorks/AccountAccessView.swift @@ -121,7 +121,7 @@ struct AccountAccessView: View { Button(title) { Task { await action() } }.frame(minHeight: 48) .disabled(form.busy || tick.date < form.resendAfter) if tick.date < form.resendAfter { - Text("Try again in \(max(1, Int(ceil(form.resendAfter.timeIntervalSince(tick.date)))))s.") + Text(CooldownCopy.retryMessage(until: form.resendAfter, now: tick.date)) .font(.caption).foregroundStyle(Color.swMuted) } } diff --git a/SpaceWorks/AccountViews.swift b/SpaceWorks/AccountViews.swift index 5183b15..84abae8 100644 --- a/SpaceWorks/AccountViews.swift +++ b/SpaceWorks/AccountViews.swift @@ -32,6 +32,7 @@ struct AccountView: View { } SWCard { Text("Privacy and help").font(.title2.bold()) + NavigationLink("App guide") { AppGuideView(isDemo: state.isDemo) }.frame(minHeight: 48).accessibilityIdentifier("app-guide") if let url = AccountLinks.safe(privacyURL) { Link("Privacy policy", destination: url).frame(minHeight: 48) } else { Text("Your makerspace hasn't provided a privacy policy link.").foregroundStyle(Color.swMuted) } if let url = AccountLinks.safe(supportURL) { Link("Contact support", destination: url).frame(minHeight: 48) } @@ -172,7 +173,7 @@ private struct AccountPhoneView: View { TextField("Phone number", text: $state.phone).textContentType(.telephoneNumber).keyboardType(.phonePad).modifier(AccountInput()).focused($focused, equals: .phone).disabled(state.busy).accessibilityIdentifier("account-phone-number") } TimelineView(.periodic(from: .now, by: 1)) { tick in - Button(tick.date < state.resendAfter ? "Send another code in \(max(1, Int(ceil(state.resendAfter.timeIntervalSince(tick.date)))))s" : state.pendingPhone == nil ? "Send verification code" : "Send another code") { Task { await state.sendPhoneCode() } } + Button(tick.date < state.resendAfter ? CooldownCopy.resendButton(until: state.resendAfter, now: tick.date) : state.pendingPhone == nil ? "Send verification code" : "Send another code") { Task { await state.sendPhoneCode() } } .frame(minHeight: 48).disabled(!state.canRequest || tick.date < state.resendAfter).accessibilityIdentifier("account-phone-send") } Button("Remove linked phone", role: .destructive) { unlink = true }.frame(minHeight: 48).disabled(!state.canRequest) diff --git a/SpaceWorks/AppGuideView.swift b/SpaceWorks/AppGuideView.swift new file mode 100644 index 0000000..cc93a9f --- /dev/null +++ b/SpaceWorks/AppGuideView.swift @@ -0,0 +1,78 @@ +import SwiftUI + +/// Task guidance contains no account data and never performs a server or SDK action. +struct AppGuideView: View { + var isDemo = false + var staffAvailable = false + var serverLabel: String? = nil + var replayTips: (() -> Void)? = nil + @State private var replayIntroduction = false + @State private var tipsReset = false + @Environment(\.dismiss) private var dismiss + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + Text("App guide").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text("Make the most of your makerspace, at your own pace.").foregroundStyle(Color.swMuted) + if isDemo { Text("Demo changes stay in this preview.").foregroundStyle(Color.swMuted) } + SWCard { + Button("Replay introduction") { replayIntroduction = true }.frame(minHeight: 48).accessibilityIdentifier("guide-replay-introduction") + if let replayTips { + Button("Reset helpful tips") { replayTips(); tipsReset = true }.frame(minHeight: 48).accessibilityIdentifier("guide-reset-tips") + if tipsReset { Text("Helpful tips will appear again on Home.").foregroundStyle(Color.swMuted).accessibilityIdentifier("guide-tips-reset") } + } + } + SWCard { + DisclosureGroup { + Text("Use Home to see due loans, bookings, events and notices. Choose a makerspace from the switcher; your activity and permissions belong to that makerspace.").padding(.top, 8) + } label: { Label("Find your way around", systemImage: "map") }.frame(minHeight: 48, alignment: .leading).accessibilityIdentifier("guide-navigation") + DisclosureGroup { + Text("Open Inventory, search or filter, then choose an item. Add the items you need and review quantities before submitting your request. Staff confirm availability, collection and borrowing terms.").padding(.top, 8) + Text("In Activity, check each active loan's due date. Choose Renew loan when available. A renewal is confirmed only when your makerspace returns a new due date.") + } label: { Label("Borrow tools", systemImage: "shippingbox") }.frame(minHeight: 48, alignment: .leading).accessibilityIdentifier("guide-borrow") + DisclosureGroup { + Text("In Activity, choose Find a space. Set Starts and Ends, then choose Check availability. Review opening hours and booked times, complete any required answers and review your request. Pending approval is different from a confirmed booking.").padding(.top, 8) + Text("Open your booking to review its current status and available cancellation action. Confirm cancellation when prompted.") + } label: { Label("Book a space", systemImage: "calendar") }.frame(minHeight: 48, alignment: .leading).accessibilityIdentifier("guide-bookings") + DisclosureGroup { + Text("Open an event, review its details and ticket choices, then confirm registration. Your result may be registered, awaiting approval or waitlisted. Open the confirmed registration in Activity to see its ticket.").padding(.top, 8) + Text("A ticket code is shown only while it remains valid. Staff verify attendance; displaying a code alone does not confirm check-in or payment.") + } label: { Label("Join events", systemImage: "ticket") }.frame(minHeight: 48, alignment: .leading).accessibilityIdentifier("guide-events") + DisclosureGroup { + Text("Use Inbox for updates even when device notifications are off. In Profile, choose Notifications when you want alerts. Permission is requested only after you choose Enable notifications; you can change it later in Settings.").padding(.top, 8) + Text("Communication preferences control optional updates from your makerspace. Required safety messages remain on. Opening an update checks current access and reloads its destination.") + } label: { Label("Keep up to date", systemImage: "bell") }.frame(minHeight: 48, alignment: .leading).accessibilityIdentifier("guide-notifications") + DisclosureGroup { + Text("Open Payments from Home, Activity or Profile. Review the amount, currency and charge before continuing to the configured checkout. The app checks the server after checkout; a payment is complete only when its status is confirmed.").padding(.top, 8) + Text("If confirmation is delayed, refresh the status before trying another payment. Open Receipts and credit notes to view or share an available document. Demo checkout never charges you.") + } label: { Label("Payments and receipts", systemImage: "creditcard") }.frame(minHeight: 48, alignment: .leading).accessibilityIdentifier("guide-payments") + DisclosureGroup { + Text("In Profile, open Account and security to manage your password, two-step sign-in, verified phone and connected sign-in methods. Devices shows native app sessions; available makerspace Sessions are listed separately.").padding(.top, 8) + Text("Save new recovery codes before continuing. Some security changes end your sessions and require a fresh sign-in. A verified phone and an editable contact phone serve different purposes.") + Text("Cancel renewal keeps the current term as your makerspace permits; leaving ends that membership. Neither action deletes your whole account. Data exports and account deletion depend on the server's available account controls.") + } label: { Label("Protect your account", systemImage: "lock.shield") }.frame(minHeight: 48, alignment: .leading).accessibilityIdentifier("guide-account") + if staffAvailable { + DisclosureGroup { + Text("Open Staff tools to review queues and approvals available to your role. Resolve the person or request before confirming an action. For issue, return or direct handout, check the items, capture required evidence and review the final confirmation.").padding(.top, 8) + Text("Scanning finds a record; it never confirms handover or attendance by itself. Check the resolved person, then choose Confirm check-in. If access changes or a conflict appears, refresh before continuing.") + Text("Camera permission is requested only when you choose Scan or Take evidence photo. If scanning is unavailable, use manual entry. Evidence photos require a camera; the app explains permission or hardware problems at that step.") + } label: { Label("Staff handovers and check-in", systemImage: "person.badge.key") }.frame(minHeight: 48, alignment: .leading).accessibilityIdentifier("guide-staff") + } + }.foregroundStyle(Color.swInk) + SWCard { + Text("About this app").font(.title2.bold()) + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "—" + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "—" + Text("Version \(version) (\(build))").textSelection(.enabled) + if let serverLabel { Text("Server").font(.headline); Text(verbatim: serverLabel).textSelection(.enabled) } + if let privacy = Brand.privacyURL.flatMap(URL.init(string:)), let safe = AccountLinks.safe(privacy) { Link("Privacy policy", destination: safe).frame(minHeight: 48) } + if let support = Brand.supportURL.flatMap(URL.init(string:)), let safe = AccountLinks.safe(support) { Link("Contact support", destination: safe).frame(minHeight: 48) } + Text("Ask your makerspace for help with unavailable features, permissions or account records.").foregroundStyle(Color.swMuted) + } + }.padding(24).frame(maxWidth: 900, alignment: .leading).frame(maxWidth: .infinity) + }.background(Color.swBg).foregroundStyle(Color.swInk).tint(Color.swAccentInk) + .navigationTitle("App guide").navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() }.frame(minHeight: 48).accessibilityIdentifier("guide-done") } } + .sheet(isPresented: $replayIntroduction) { IntroductionView { replayIntroduction = false } } + } +} diff --git a/SpaceWorks/AuthView.swift b/SpaceWorks/AuthView.swift index 693eb53..6d23118 100644 --- a/SpaceWorks/AuthView.swift +++ b/SpaceWorks/AuthView.swift @@ -186,15 +186,18 @@ struct BrandWordmark: View { @Environment(\.verticalSizeClass) private var verticalSize @Environment(\.dynamicTypeSize) private var typeSize var body: some View { - if Brand.appName == "Space Works" { - if compact || verticalSize == .compact || typeSize.isAccessibilitySize { + let compactLayout = compact || verticalSize == .compact || typeSize.isAccessibilitySize + let originalBrand = Brand.appName == "Space Works" + let useSymbol = Brand.hasCustomWordmark ? (compactLayout ? Brand.hasCustomSymbol : !Brand.hasCustomStacked) : compactLayout + if originalBrand || Brand.hasCustomWordmark { + if useSymbol { Image("BrandSymbol").resizable().scaledToFit().frame(width: 52, height: 48) - .padding(12).accessibilityLabel("Space Works") + .padding(12).accessibilityLabel(Text(verbatim: Brand.appName)) } else { Image("BrandStacked").resizable().scaledToFit().frame(width: spacious ? 280 : 180, height: spacious ? 100 : 64) - .padding(16).accessibilityLabel("Space Works") + .padding(16).accessibilityLabel(Text(verbatim: Brand.appName)) } - } else { Text(Brand.appName).font(.headline).foregroundStyle(Color.swAccentInk) } + } else { Text(verbatim: Brand.appName).font(.headline).foregroundStyle(Color.swAccentInk) } } } diff --git a/SpaceWorks/CooldownCopy.swift b/SpaceWorks/CooldownCopy.swift new file mode 100644 index 0000000..ba158b5 --- /dev/null +++ b/SpaceWorks/CooldownCopy.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Formats a retained deadline without converting an unbounded server interval to Int. +/// The display never changes the deadline or authorizes a request. +enum CooldownCopy { + private static func seconds(until deadline: Date, now: Date) -> Int? { + let remaining = deadline.timeIntervalSince(now) + guard remaining.isFinite else { return nil } + return Int(exactly: max(1, remaining.rounded(.up))) + } + static func retryMessage(until deadline: Date, now: Date) -> String { + guard let value = seconds(until: deadline, now: now) else { return String(localized: "Try again later.") } + return String(localized: "Try again in \(value)s.") + } + static func retryButton(until deadline: Date, now: Date) -> String { + guard let value = seconds(until: deadline, now: now) else { return String(localized: "Try again later") } + return String(localized: "Try again in \(value)s") + } + static func resendButton(until deadline: Date, now: Date) -> String { + guard let value = seconds(until: deadline, now: now) else { return String(localized: "Send another code later") } + return String(localized: "Send another code in \(value)s") + } +} diff --git a/SpaceWorks/DemoGuidance.swift b/SpaceWorks/DemoGuidance.swift new file mode 100644 index 0000000..b641751 --- /dev/null +++ b/SpaceWorks/DemoGuidance.swift @@ -0,0 +1,20 @@ +#if DEBUG +import Foundation + +struct DemoGuidanceKey: Hashable { + let role: DemoSession.Role + let makerspaceID: Int + let featureVersion: String +} + +@MainActor extension DemoSession { + func makeGuidedMemberState(repository: any MemberRepository, makerspaceID: Int, featureVersion: String = "member-v1") -> MemberState { + let key = DemoGuidanceKey(role: role, makerspaceID: makerspaceID, featureVersion: featureVersion) + let owner = guidanceEpoch + return MemberState(repository: repository, initialGuidance: guidanceFlags[key] ?? true, guidanceChanged: { [weak self] visible in + guard let self, self.guidanceEpoch == owner else { return } + self.guidanceFlags[key] = visible + }) + } +} +#endif diff --git a/SpaceWorks/DemoMode.swift b/SpaceWorks/DemoMode.swift index 9ee30ed..c9bbfdc 100644 --- a/SpaceWorks/DemoMode.swift +++ b/SpaceWorks/DemoMode.swift @@ -33,6 +33,8 @@ final class DemoSession { var pendingAccountFix: DemoDeletionState.Blocker? @ObservationIgnored var globalAccountRepository: DemoAccountRepository? @ObservationIgnored var memberAccountRepositories: [Int: DemoAccountRepository] = [:] + @ObservationIgnored var guidanceFlags: [DemoGuidanceKey: Bool] = [:] + @ObservationIgnored var guidanceEpoch = UUID() var notificationState = DemoNotificationState() @ObservationIgnored private var paymentRepositories: [Int: DemoPaymentRepository] = [:] var staffScenario = DemoStaffScenario.populated @@ -84,7 +86,7 @@ final class DemoSession { if let repository = paymentRepositories[makerspaceID] { return repository } let repository = DemoPaymentRepository(); paymentRepositories[makerspaceID] = repository; return repository } - func reset() { accountCooldown = AccountAccessCooldown(); emailCooldown = AccountAccessCooldown(); demoEmailConfirmed = false; emailFlowID = UUID(); fixNotice = nil; availableSpaces = [0, 1]; globalAccountRepository = nil; memberAccountRepositories = [:]; securityEnrollment = false; securityCodes = []; securityMessage = nil; pendingAccountFix = nil; paymentRepositories = [:]; notificationState = DemoNotificationState(); username = ""; password = ""; code = ""; error = nil; retryAfter = nil; selectedSpace = 0; profileDisplayName = nil; actionScenario = .success; stage = .welcome } + func reset() { guidanceFlags = [:]; guidanceEpoch = UUID(); accountCooldown = AccountAccessCooldown(); emailCooldown = AccountAccessCooldown(); demoEmailConfirmed = false; emailFlowID = UUID(); fixNotice = nil; availableSpaces = [0, 1]; globalAccountRepository = nil; memberAccountRepositories = [:]; securityEnrollment = false; securityCodes = []; securityMessage = nil; pendingAccountFix = nil; paymentRepositories = [:]; notificationState = DemoNotificationState(); username = ""; password = ""; code = ""; error = nil; retryAfter = nil; selectedSpace = 0; profileDisplayName = nil; actionScenario = .success; stage = .welcome } } enum DemoProvider: String, CaseIterable, Identifiable { @@ -101,7 +103,11 @@ struct DemoRoot: View { HStack { Text("Demo mode · sample data").font(.caption.bold()) Spacer() - Button("Exit demo") { session.reset(); exit() }.frame(minHeight: 48) + Button { session.reset(); exit() } label: { + Label("Exit demo", systemImage: "xmark").labelStyle(.iconOnly) + .font(.title3).frame(minWidth: 48, minHeight: 48) + .contentShape(Rectangle()) + } }.padding(.horizontal, 20).foregroundStyle(Color.swOnEmphasis).background(Color.swEmphasis) if session.stage == .shell { DemoShell(session: session, linkRouter: linkRouter).id("\(session.role.rawValue).\(session.selectedSpace).\(session.memberScenario.rawValue)") @@ -303,7 +309,7 @@ struct DemoShell: View { _cart = State(initialValue: RequestCartState(repository: actions)) _drafts = State(initialValue: MemberActionDrafts(repository: actions)) _capabilities = State(initialValue: MemberCapabilities(repository: actions, makerspaceID: session.selectedSpace + 7)) - _member = State(initialValue: MemberState(repository: DemoMemberRepository(scenario: session.memberScenario, name: session.name, store: store))) + _member = State(initialValue: session.makeGuidedMemberState(repository: DemoMemberRepository(scenario: session.memberScenario, name: session.name, store: store), makerspaceID: session.selectedSpace + 7)) } var body: some View { GeometryReader { geometry in @@ -401,7 +407,7 @@ struct DemoDestination: View { let linkRouter: MobileLinkRouter var body: some View { Group { - if tab != .profile { MemberContent(state: member, tab: tab, name: session.name, selectTab: { navigation.selected = $0 }, actions: actions, cart: cart, navigation: navigation, drafts: drafts, paymentsAvailable: true, openLink: { text in + if tab != .profile { MemberContent(state: member, tab: tab, name: session.name, selectTab: { navigation.selected = $0 }, actions: actions, cart: cart, navigation: navigation, drafts: drafts, paymentsAvailable: true, guidanceBookingsEnabled: capabilities.bootstrap?.modules.contains("bookable_spaces") == true, openLink: { text in guard let origin = try? ServerOrigin("https://demo.spaceworks.invalid") else { return } if let link = MobileLink.inbox(text, origin: origin) { linkRouter.receive(link) } else if let legacy = MobileLink.legacy(text, origin: origin) { linkRouter.receive(legacy) } @@ -431,6 +437,9 @@ struct DemoDestination: View { Picker("Action preview result", selection: Binding(get: { session.actionScenario }, set: { session.actionScenario = $0 })) { ForEach(DemoMemberActionsRepository.Scenario.allCases, id: \.self) { Text(LocalizedStringKey($0.rawValue)) } }.frame(minHeight: 48).accessibilityIdentifier("action-preview-result") + NavigationLink("App guide") { + AppGuideView(isDemo: true, staffAvailable: staff != nil, replayTips: member.replayGuidance) + }.frame(minHeight: 48).accessibilityIdentifier("app-guide") Button("Show helpful tips") { member.replayGuidance() }.frame(minHeight: 48) Button("Sign out") { session.reset() }.frame(minHeight: 48) } @@ -441,7 +450,7 @@ struct DemoDestination: View { ToolbarItem(placement: .topBarTrailing) { Menu { ForEach(session.spaces.indices.filter { session.availableSpaces.contains($0) }, id: \.self) { index in Button(session.spaces[index]) { linkRouter.cancelIntent(); session.selectedSpace = index } } - } label: { Label(session.spaces[session.selectedSpace], systemImage: "building.2") } + } label: { Label(session.spaces[session.selectedSpace], systemImage: "building.2") }.accessibilityIdentifier("demo-makerspace-switcher") } } } diff --git a/SpaceWorks/MemberActionViews.swift b/SpaceWorks/MemberActionViews.swift index 7bb4b8d..d2486d4 100644 --- a/SpaceWorks/MemberActionViews.swift +++ b/SpaceWorks/MemberActionViews.swift @@ -54,7 +54,7 @@ struct RequestCartView: View { }.interactiveDismissDisabled(state.busy) } @ViewBuilder private func submission(at date: Date) -> some View { - if let deadline = state.retryAfter, date < deadline { Text("Try again in \(max(1, Int(ceil(deadline.timeIntervalSince(date)))))s.").foregroundStyle(Color.swMuted) } + if let deadline = state.retryAfter, date < deadline { Text(CooldownCopy.retryMessage(until: deadline, now: date)).foregroundStyle(Color.swMuted) } Button(state.busy ? "Submitting…" : "Submit request") { Task { await state.submit(); if state.receipt != nil { await completed() } } }.buttonStyle(SWPrimary()).disabled(state.busy || state.lines.isEmpty || (state.retryAfter ?? .distantPast) > date) diff --git a/SpaceWorks/MemberBookingsView.swift b/SpaceWorks/MemberBookingsView.swift new file mode 100644 index 0000000..b760672 --- /dev/null +++ b/SpaceWorks/MemberBookingsView.swift @@ -0,0 +1,39 @@ +import SwiftUI +import SpaceWorksAPI + +/// Existing authenticated booking records for the current account/makerspace only. +struct MemberBookingsView: View { + @Bindable var state: MemberState + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + Text("Bookings").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + NavigationLink("Find a space", value: MemberRoute.spaces).frame(minHeight: 48) + LoadFeedback(load: state.bookingsLoad, lastUpdated: state.updated["bookings"], retryAfter: state.deadline("bookings")) { await state.loadBookings() } + if state.bookingsLoad == .ready && state.bookings.isEmpty { + SWCard { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } + } + ForEach(state.bookings, id: \.public_token) { row in + SWCard { + NavigationLink(row.space_name, value: MemberRoute.booking(row.public_token)).font(.headline).frame(minHeight: 48) + Text(guideBookingDate(row.starts_at, timezone: row.timezone)) + Text(NativeStatusCopy.label(row.status.value1.rawValue)).foregroundStyle(Color.swMuted) + } + } + if state.bookingsHasMore { + Button("Load more bookings") { Task { await state.loadBookings(more: true) } }.frame(minHeight: 48).disabled(state.bookingsLoad == .loading) + } + }.padding(24).frame(maxWidth: 900, alignment: .leading).frame(maxWidth: .infinity) + }.background(Color.swBg).navigationTitle("Bookings").navigationBarTitleDisplayMode(.inline) + .accessibilityIdentifier("member-bookings-list") + .task { await state.loadBookings() }.refreshable { await state.loadBookings() } + } +} + +private func guideBookingDate(_ date: Date, timezone: String) -> String { + let zone = TimeZone(identifier: timezone) + let formatter = DateFormatter() + formatter.locale = .current; formatter.dateStyle = .medium; formatter.timeStyle = .short + formatter.timeZone = zone ?? TimeZone(secondsFromGMT: 0) + return formatter.string(from: date) + " · " + (zone == nil ? "UTC" : timezone) +} diff --git a/SpaceWorks/MemberNavigation.swift b/SpaceWorks/MemberNavigation.swift index 7df87ea..51ef183 100644 --- a/SpaceWorks/MemberNavigation.swift +++ b/SpaceWorks/MemberNavigation.swift @@ -1,7 +1,7 @@ import SwiftUI enum MemberRoute: Hashable { - case product(Int), spaces, space(String), event(String), booking(String), registration(Int), contact, preferences, payments, payment(Int), staff, account + case product(Int), bookings, spaces, space(String), event(String), booking(String), registration(Int), contact, preferences, payments, payment(Int), staff, account } /// Memory-only navigation belongs to one authenticated account/makerspace, outside adaptive branches. @@ -17,6 +17,10 @@ final class MemberNavigation { }) } func open(_ route: MemberRoute, tab: AppTab) { selected = tab; paths[tab] = [route] } + func openBookings() { + selected = .activity + if paths[.activity, default: []].last != .bookings { paths[.activity, default: []].append(.bookings) } + } func compactInventory() { guard selected == .inventory, let inventorySelection, paths[.inventory, default: []].isEmpty else { return } paths[.inventory] = [.product(inventorySelection)] diff --git a/SpaceWorks/MemberRoutes.swift b/SpaceWorks/MemberRoutes.swift index d8a86f2..a86d59f 100644 --- a/SpaceWorks/MemberRoutes.swift +++ b/SpaceWorks/MemberRoutes.swift @@ -22,6 +22,7 @@ struct MemberRouteView: View { var body: some View { switch route { case .product(let id): InventoryDetail(repository: member.repository, id: id, cart: cart, drafts: drafts) { await member.loadActivity(); drafts.registrationCancelled() } + case .bookings: MemberBookingsView(state: member) case .spaces: SpaceDiscoveryView(member: member, repository: actions, isDemo: isDemo) { await member.loadBookings() } case .space(let token): if let space = member.spaces.first(where: { $0.public_token == token }) { diff --git a/SpaceWorks/MemberState.swift b/SpaceWorks/MemberState.swift index 8c91859..671047d 100644 --- a/SpaceWorks/MemberState.swift +++ b/SpaceWorks/MemberState.swift @@ -52,6 +52,7 @@ final class MemberState { private(set) var showGuidance: Bool private let settings: UserDefaults? private let guidanceKey: String? + private let guidanceChanged: ((Bool) -> Void)? private var nextInventoryPage: Int? private var nextInboxPage: Int? private var nextBookingsPage: Int? @@ -66,14 +67,14 @@ final class MemberState { private var unreadLoading = false private let now: () -> Date - init(repository: any MemberRepository, settings: UserDefaults? = nil, context: String? = nil, now: @escaping () -> Date = Date.init) { + init(repository: any MemberRepository, settings: UserDefaults? = nil, context: String? = nil, now: @escaping () -> Date = Date.init, initialGuidance: Bool? = nil, guidanceChanged: ((Bool) -> Void)? = nil) { self.repository = repository; self.settings = settings - self.now = now + self.now = now; self.guidanceChanged = guidanceChanged self.guidanceKey = context.map { "guidance.member-v1." + $0 } - self.showGuidance = context.map { settings?.object(forKey: "guidance.member-v1." + $0) as? Bool ?? true } ?? true + self.showGuidance = initialGuidance ?? context.map { settings?.object(forKey: "guidance.member-v1." + $0) as? Bool ?? true } ?? true } - func dismissGuidance() { showGuidance = false; if let guidanceKey { settings?.set(false, forKey: guidanceKey) } } - func replayGuidance() { showGuidance = true; if let guidanceKey { settings?.set(true, forKey: guidanceKey) } } + func dismissGuidance() { showGuidance = false; if let guidanceKey { settings?.set(false, forKey: guidanceKey) }; guidanceChanged?(false) } + func replayGuidance() { showGuidance = true; if let guidanceKey { settings?.set(true, forKey: guidanceKey) }; guidanceChanged?(true) } func invalidate() { active = false; inventoryGeneration += 1; inboxGeneration += 1; cardGeneration += 1; bookingsGeneration += 1 products = []; categories = []; activity = nil; bookings = []; events = []; spaces = []; card = nil; messages = []; unreadCount = nil; updated = [:] diff --git a/SpaceWorks/MemberViews.swift b/SpaceWorks/MemberViews.swift index ea9d5fe..9d9c4b5 100644 --- a/SpaceWorks/MemberViews.swift +++ b/SpaceWorks/MemberViews.swift @@ -12,6 +12,7 @@ struct MemberContent: View { var navigation: MemberNavigation? = nil var drafts: MemberActionDrafts? = nil var paymentsAvailable = false + var guidanceBookingsEnabled = true var openLink: ((String) -> Void)? = nil @State private var showCard = false @State private var renewal: Components.Schemas.MemberLoanActivity? @@ -29,10 +30,11 @@ struct MemberContent: View { Text("Your makerspace, at a glance.").foregroundStyle(Color.swMuted) if state.showGuidance { SWCard { - HStack { Text("Start here").font(.headline); Spacer(); Button("Dismiss") { state.dismissGuidance() }.frame(minHeight: 48) } + HStack { Text("Start here").font(.headline); Spacer(); Button("Dismiss") { state.dismissGuidance() }.frame(minHeight: 48).accessibilityIdentifier("guidance-dismiss") } Text("Find what you need, then request it from your makerspace.").foregroundStyle(Color.swMuted) Button("Browse inventory") { selectTab(.inventory) }.buttonStyle(SWPrimary()) - Button("View bookings") { selectTab(.activity) }.frame(minHeight: 48) + if guidanceBookingsEnabled { Button("View bookings") { if let navigation { navigation.openBookings() } else { selectTab(.activity) } }.frame(minHeight: 48) } + else { Button("View profile") { selectTab(.profile) }.frame(minHeight: 48) } } } } @@ -198,7 +200,7 @@ struct LoadFeedback: View { SWError(message: message) if let lastUpdated { Text("Last updated \(lastUpdated.formatted(date: .abbreviated, time: .shortened))").font(.caption).foregroundStyle(Color.swMuted) } TimelineView(.periodic(from: .now, by: 1)) { tick in - Button((retryAfter ?? .distantPast) > tick.date ? "Try again in \(max(1, Int(ceil((retryAfter ?? tick.date).timeIntervalSince(tick.date)))))s" : "Try again") { Task { await retry() } } + Button((retryAfter ?? .distantPast) > tick.date ? CooldownCopy.retryButton(until: retryAfter ?? tick.date, now: tick.date) : "Try again") { Task { await retry() } } .frame(minHeight: 48).disabled((retryAfter ?? .distantPast) > tick.date) } } diff --git a/SpaceWorks/Resources/Localizable.xcstrings b/SpaceWorks/Resources/Localizable.xcstrings index 2b87531..7c001a4 100644 --- a/SpaceWorks/Resources/Localizable.xcstrings +++ b/SpaceWorks/Resources/Localizable.xcstrings @@ -78,6 +78,17 @@ } } }, + "A ticket code is shown only while it remains valid. Staff verify attendance; displaying a code alone does not confirm check-in or payment.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A ticket code is shown only while it remains valid. Staff verify attendance; displaying a code alone does not confirm check-in or payment." + } + } + } + }, "A ticket is available only after registration is confirmed.": { "extractionState": "manual", "localizations": { @@ -100,6 +111,17 @@ } } }, + "About this app": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "About this app" + } + } + } + }, "About this item": { "extractionState": "manual", "localizations": { @@ -419,6 +441,17 @@ } } }, + "App guide": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "App guide" + } + } + } + }, "Apple": { "extractionState": "manual", "localizations": { @@ -463,6 +496,17 @@ } } }, + "Ask your makerspace for help with unavailable features, permissions or account records.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask your makerspace for help with unavailable features, permissions or account records." + } + } + } + }, "Ask your makerspace for help, or check the server address.": { "extractionState": "manual", "localizations": { @@ -716,6 +760,17 @@ } } }, + "Borrow tools": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Borrow tools" + } + } + } + }, "Borrow with confidence.": { "extractionState": "manual", "localizations": { @@ -804,6 +859,17 @@ } } }, + "Camera permission is requested only when you choose Scan or Take evidence photo. If scanning is unavailable, use manual entry. Evidence photos require a camera; the app explains permission or hardware problems at that step.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Camera permission is requested only when you choose Scan or Take evidence photo. If scanning is unavailable, use manual entry. Evidence photos require a camera; the app explains permission or hardware problems at that step." + } + } + } + }, "Camera request cancelled.": { "extractionState": "manual", "localizations": { @@ -914,6 +980,17 @@ } } }, + "Cancel renewal keeps the current term as your makerspace permits; leaving ends that membership. Neither action deletes your whole account. Data exports and account deletion depend on the server's available account controls.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel renewal keeps the current term as your makerspace permits; leaving ends that membership. Neither action deletes your whole account. Data exports and account deletion depend on the server's available account controls." + } + } + } + }, "Canceled": { "extractionState": "manual", "localizations": { @@ -1519,6 +1596,17 @@ } } }, + "Communication preferences control optional updates from your makerspace. Required safety messages remain on. Opening an update checks current access and reloads its destination.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Communication preferences control optional updates from your makerspace. Required safety messages remain on. Opening an update checks current access and reloads its destination." + } + } + } + }, "Confirm acceptance": { "extractionState": "manual", "localizations": { @@ -1849,6 +1937,17 @@ } } }, + "Dark mode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dark mode" + } + } + } + }, "Data export": { "extractionState": "manual", "localizations": { @@ -1915,6 +2014,17 @@ } } }, + "Demo changes stay in this preview.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo changes stay in this preview." + } + } + } + }, "Demo code ready. Use 123456. No email was sent.": { "extractionState": "manual", "localizations": { @@ -2960,6 +3070,17 @@ } } }, + "Find your way around": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Find your way around" + } + } + } + }, "First name": { "extractionState": "manual", "localizations": { @@ -3026,6 +3147,17 @@ } } }, + "Helpful tips will appear again on Home.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Helpful tips will appear again on Home." + } + } + } + }, "Hidden": { "extractionState": "manual", "localizations": { @@ -3092,6 +3224,17 @@ } } }, + "If confirmation is delayed, refresh the status before trying another payment. Open Receipts and credit notes to view or share an available document. Demo checkout never charges you.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "If confirmation is delayed, refresh the status before trying another payment. Open Receipts and credit notes to view or share an available document. Demo checkout never charges you." + } + } + } + }, "If you don't have a current password, sign out and use Forgot password to recover access.": { "extractionState": "manual", "localizations": { @@ -3103,6 +3246,39 @@ } } }, + "In Activity, check each active loan's due date. Choose Renew loan when available. A renewal is confirmed only when your makerspace returns a new due date.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "In Activity, check each active loan's due date. Choose Renew loan when available. A renewal is confirmed only when your makerspace returns a new due date." + } + } + } + }, + "In Activity, choose Find a space. Set Starts and Ends, then choose Check availability. Review opening hours and booked times, complete any required answers and review your request. Pending approval is different from a confirmed booking.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "In Activity, choose Find a space. Set Starts and Ends, then choose Check availability. Review opening hours and booked times, complete any required answers and review your request. Pending approval is different from a confirmed booking." + } + } + } + }, + "In Profile, open Account and security to manage your password, two-step sign-in, verified phone and connected sign-in methods. Devices shows native app sessions; available makerspace Sessions are listed separately.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "In Profile, open Account and security to manage your password, two-step sign-in, verified phone and connected sign-in methods. Devices shows native app sessions; available makerspace Sessions are listed separately." + } + } + } + }, "In a connected app, this opens notification settings for your device.": { "extractionState": "manual", "localizations": { @@ -3202,6 +3378,17 @@ } } }, + "Join events": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Join events" + } + } + } + }, "Keep booking": { "extractionState": "manual", "localizations": { @@ -3301,6 +3488,17 @@ } } }, + "Keep up to date": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep up to date" + } + } + } + }, "Keep up with requests, bookings, and important makerspace updates. Your inbox is always available.": { "extractionState": "manual", "localizations": { @@ -3400,6 +3598,17 @@ } } }, + "Light mode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Light mode" + } + } + } + }, "Limited": { "extractionState": "manual", "localizations": { @@ -3664,6 +3873,17 @@ } } }, + "Make the most of your makerspace, at your own pace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Make the most of your makerspace, at your own pace." + } + } + } + }, "Make yourself at home.": { "extractionState": "manual", "localizations": { @@ -4104,6 +4324,17 @@ } } }, + "Open Inventory, search or filter, then choose an item. Add the items you need and review quantities before submitting your request. Staff confirm availability, collection and borrowing terms.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Inventory, search or filter, then choose an item. Add the items you need and review quantities before submitting your request. Staff confirm availability, collection and borrowing terms." + } + } + } + }, "Open PDF": { "extractionState": "manual", "localizations": { @@ -4115,6 +4346,39 @@ } } }, + "Open Payments from Home, Activity or Profile. Review the amount, currency and charge before continuing to the configured checkout. The app checks the server after checkout; a payment is complete only when its status is confirmed.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Payments from Home, Activity or Profile. Review the amount, currency and charge before continuing to the configured checkout. The app checks the server after checkout; a payment is complete only when its status is confirmed." + } + } + } + }, + "Open Staff tools to review queues and approvals available to your role. Resolve the person or request before confirming an action. For issue, return or direct handout, check the items, capture required evidence and review the final confirmation.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Staff tools to review queues and approvals available to your role. Resolve the person or request before confirming an action. For issue, return or direct handout, check the items, capture required evidence and review the final confirmation." + } + } + } + }, + "Open an event, review its details and ticket choices, then confirm registration. Your result may be registered, awaiting approval or waitlisted. Open the confirmed registration in Activity to see its ticket.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open an event, review its details and ticket choices, then confirm registration. Your result may be registered, awaiting approval or waitlisted. Open the confirmed registration in Activity to see its ticket." + } + } + } + }, "Open check-in": { "extractionState": "manual", "localizations": { @@ -4192,6 +4456,17 @@ } } }, + "Open your booking to review its current status and available cancellation action. Confirm cancellation when prompted.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open your booking to review its current status and available cancellation action. Confirm cancellation when prompted." + } + } + } + }, "Opening hours": { "extractionState": "manual", "localizations": { @@ -4467,6 +4742,17 @@ } } }, + "Payments and receipts": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Payments and receipts" + } + } + } + }, "Payments are still loading. Try again in a moment.": { "extractionState": "manual", "localizations": { @@ -4874,6 +5160,17 @@ } } }, + "Protect your account": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Protect your account" + } + } + } + }, "Push notifications": { "extractionState": "manual", "localizations": { @@ -5281,6 +5578,17 @@ } } }, + "Replay introduction": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Replay introduction" + } + } + } + }, "Request an export of your personal data held by this makerspace. Keep downloaded files somewhere private.": { "extractionState": "manual", "localizations": { @@ -5435,6 +5743,17 @@ } } }, + "Reset helpful tips": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset helpful tips" + } + } + } + }, "Reset password": { "extractionState": "manual", "localizations": { @@ -5765,6 +6084,17 @@ } } }, + "Save new recovery codes before continuing. Some security changes end your sessions and require a fresh sign-in. A verified phone and an editable contact phone serve different purposes.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save new recovery codes before continuing. Some security changes end your sessions and require a fresh sign-in. A verified phone and an editable contact phone serve different purposes." + } + } + } + }, "Save or share archive": { "extractionState": "manual", "localizations": { @@ -5864,6 +6194,17 @@ } } }, + "Scanning finds a record; it never confirms handover or attendance by itself. Check the resolved person, then choose Confirm check-in. If access changes or a conflict appears, refresh before continuing.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scanning finds a record; it never confirms handover or attendance by itself. Check the resolved person, then choose Confirm check-in. If access changes or a conflict appears, refresh before continuing." + } + } + } + }, "Search": { "extractionState": "manual", "localizations": { @@ -5919,6 +6260,17 @@ } } }, + "Send another code later": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send another code later" + } + } + } + }, "Send verification code": { "extractionState": "manual", "localizations": { @@ -5930,6 +6282,17 @@ } } }, + "Server": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server" + } + } + } + }, "Server address": { "extractionState": "manual", "localizations": { @@ -6249,6 +6612,17 @@ } } }, + "Staff handovers and check-in": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Staff handovers and check-in" + } + } + } + }, "Staff tools": { "extractionState": "manual", "localizations": { @@ -7151,6 +7525,28 @@ } } }, + "Try again later": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Try again later" + } + } + } + }, + "Try again later.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Try again later." + } + } + } + }, "Turn off for this account": { "extractionState": "manual", "localizations": { @@ -7437,6 +7833,28 @@ } } }, + "Use Home to see due loans, bookings, events and notices. Choose a makerspace from the switcher; your activity and permissions belong to that makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use Home to see due loans, bookings, events and notices. Choose a makerspace from the switcher; your activity and permissions belong to that makerspace." + } + } + } + }, + "Use Inbox for updates even when device notifications are off. In Profile, choose Notifications when you want alerts. Permission is requested only after you choose Enable notifications; you can change it later in Settings.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use Inbox for updates even when device notifications are off. In Profile, choose Notifications when you want alerts. Permission is requested only after you choose Enable notifications; you can change it later in Settings." + } + } + } + }, "Use a password with no more than 128 characters.": { "extractionState": "manual", "localizations": { @@ -7701,6 +8119,17 @@ } } }, + "View profile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View profile" + } + } + } + }, "View registrations": { "extractionState": "manual", "localizations": { diff --git a/SpaceWorks/Resources/PrivacyInfo.xcprivacy b/SpaceWorks/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..f0db006 --- /dev/null +++ b/SpaceWorks/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,17 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/SpaceWorks/RootView.swift b/SpaceWorks/RootView.swift index fc24912..21d54d8 100644 --- a/SpaceWorks/RootView.swift +++ b/SpaceWorks/RootView.swift @@ -3,6 +3,7 @@ import SpaceWorksAPI struct RootView: View { @State private var state = AppState() + @Environment(\.accessibilityReduceMotion) private var systemReduceMotion @State private var linkRouter = MobileLinkRouter() @State private var pendingURL: URL? @AppStorage("introduction-complete") private var introComplete = false @@ -25,12 +26,24 @@ struct RootView: View { .tint(Color.swAccentInk) .foregroundStyle(Color.swInk) .preferredColorScheme(state.darkMode ? .dark : .light) + #if DEBUG + // UI automation exercises the app's reduced-motion environment; Release uses the OS. + .environment(\.accessibilityReduceMotion, systemReduceMotion || + (ProcessInfo.processInfo.arguments.contains("--ui-testing") && + ProcessInfo.processInfo.environment["SPACEWORKS_TEST_REDUCE_MOTION"] == "1")) + #endif .safeAreaInset(edge: .top, spacing: 0) { HStack { Spacer() - Button(state.darkMode ? "Light mode" : "Dark mode") { state.toggleAppearance() } + Button { state.toggleAppearance() } label: { + Label(state.darkMode ? String(localized: "Light mode") : String(localized: "Dark mode"), + systemImage: state.darkMode ? "sun.max" : "moon") + .labelStyle(.iconOnly) + .font(.title3).frame(minWidth: 48, minHeight: 48) + .contentShape(Rectangle()) + } .buttonStyle(.plain).foregroundStyle(Color.swAccentInk) - .font(.body).frame(minHeight: 48).padding(.horizontal, 24) + .padding(.horizontal, 24) .accessibilityIdentifier("appearance") }.background(Color.swSurface) } @@ -276,7 +289,7 @@ private struct MemberScreen: View { @State private var confirmSignOut = false var body: some View { Group { - if tab != .profile { MemberContent(state: member, tab: tab, name: auth.displayName, selectTab: { navigation.selected = $0 }, actions: actions, cart: cart, navigation: navigation, drafts: drafts, paymentsAvailable: true, openLink: { text in + if tab != .profile { MemberContent(state: member, tab: tab, name: auth.displayName, selectTab: { navigation.selected = $0 }, actions: actions, cart: cart, navigation: navigation, drafts: drafts, paymentsAvailable: true, guidanceBookingsEnabled: capabilities.bootstrap?.modules.contains("bookable_spaces") == true, openLink: { text in guard let origin = state.connectedOrigin else { linkRouter.refuseURL(); return } if let link = MobileLink.inbox(text, origin: origin) { linkRouter.receive(link) } else if let legacy = MobileLink.legacy(text, origin: origin) { linkRouter.receive(legacy) } @@ -301,6 +314,9 @@ private struct MemberScreen: View { NavigationLink("Notifications") { NotificationSettingsView(state: notifications) }.frame(minHeight: 48).accessibilityIdentifier("notifications-entry") } Button("Show introduction") { replayIntro = true }.frame(minHeight: 48) + NavigationLink("App guide") { + AppGuideView(serverLabel: state.connectedOrigin?.url.absoluteString, replayTips: member.replayGuidance) + }.frame(minHeight: 48).accessibilityIdentifier("app-guide") Button("Show helpful tips") { member.replayGuidance() }.frame(minHeight: 48) Button("Sign out", role: .destructive) { confirmSignOut = true }.frame(minHeight: 48).disabled(auth.busy) } diff --git a/SpaceWorks/SpaceWorksApp.swift b/SpaceWorks/SpaceWorksApp.swift index 6315ce5..f155c15 100644 --- a/SpaceWorks/SpaceWorksApp.swift +++ b/SpaceWorks/SpaceWorksApp.swift @@ -15,7 +15,9 @@ struct SpaceWorksApp: App { if ProcessInfo.processInfo.arguments.contains("--ui-testing") { PreviewMemberResponses.reset() UserDefaults.standard.removeObject(forKey: "server-origin") - UserDefaults.standard.removeObject(forKey: "dark-mode") + // The app has an explicit theme, so simulator appearance alone is insufficient. + let appearance = ProcessInfo.processInfo.environment["SPACEWORKS_TEST_APPEARANCE"] + UserDefaults.standard.set(appearance == "dark", forKey: "dark-mode") UserDefaults.standard.removeObject(forKey: "makerspace-selection") for key in UserDefaults.standard.dictionaryRepresentation().keys where key.hasPrefix("makerspace-selection.") { UserDefaults.standard.removeObject(forKey: key) diff --git a/SpaceWorks/StaffState.swift b/SpaceWorks/StaffState.swift index 7f2a41e..fc0de87 100644 --- a/SpaceWorks/StaffState.swift +++ b/SpaceWorks/StaffState.swift @@ -144,10 +144,15 @@ final class StaffState { if let deadline = APIError.retryDeadline(error), deadline > retryAfter { retryAfter = deadline; cooldownTask?.cancel() cooldownTask = Task { [weak self] in - let delay = max(0, deadline.timeIntervalSinceNow) - do { try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) } - catch { return } - guard let self, self.active else { return }; self.retryAfter = .distantPast + do { + while !Task.isCancelled, deadline > Date() { + guard self?.active == true, self?.retryAfter == deadline else { return } + let seconds = min(3600, max(0, deadline.timeIntervalSinceNow)) + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + } + } catch { return } + guard !Task.isCancelled, let self, self.active, self.retryAfter == deadline else { return } + self.retryAfter = .distantPast } } if [401, 403].contains(APIError.status(error)) { diff --git a/SpaceWorksTests/CooldownCopyTests.swift b/SpaceWorksTests/CooldownCopyTests.swift new file mode 100644 index 0000000..b302837 --- /dev/null +++ b/SpaceWorksTests/CooldownCopyTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import SpaceWorks + +final class CooldownCopyTests: XCTestCase { + func testOrdinaryFractionalDeadlineRoundsUpForAllControls() { + let now = Date(timeIntervalSince1970: 10_000), deadline = Date(timeIntervalSince1970: 10_060.25) + XCTAssertEqual(CooldownCopy.retryMessage(until: deadline, now: now), "Try again in 61s.") + XCTAssertEqual(CooldownCopy.retryButton(until: deadline, now: now), "Try again in 61s") + XCTAssertEqual(CooldownCopy.resendButton(until: deadline, now: now), "Send another code in 61s") + } + func testFiniteUnrepresentableServerDeadlineHasSafeCopy() { + let now = Date(timeIntervalSince1970: 0), deadline = Date(timeIntervalSince1970: 1e20) + XCTAssertEqual(CooldownCopy.retryMessage(until: deadline, now: now), "Try again later.") + XCTAssertEqual(CooldownCopy.retryButton(until: deadline, now: now), "Try again later") + XCTAssertEqual(CooldownCopy.resendButton(until: deadline, now: now), "Send another code later") + } + func testNonfiniteAndIntBoundaryCannotTrapTheDisplay() { + let now = Date(timeIntervalSince1970: 0) + for interval in [Double.infinity, Double.nan, Double(Int.max)] { + let deadline = Date(timeIntervalSince1970: interval) + XCTAssertEqual(CooldownCopy.retryMessage(until: deadline, now: now), "Try again later.") + } + let representable = Date(timeIntervalSince1970: Double(Int.max).nextDown) + XCTAssertEqual(CooldownCopy.retryMessage(until: representable, now: now), "Try again in 9223372036854774784s.") + } +} diff --git a/SpaceWorksTests/DemoGuidanceTests.swift b/SpaceWorksTests/DemoGuidanceTests.swift new file mode 100644 index 0000000..fb8db38 --- /dev/null +++ b/SpaceWorksTests/DemoGuidanceTests.swift @@ -0,0 +1,38 @@ +#if DEBUG +import XCTest +@testable import SpaceWorks + +@MainActor final class DemoGuidanceTests: XCTestCase { + func testMakerspaceRemountPreservesBothDismissedTips() { + let session = DemoSession() + let first = session.makeGuidedMemberState(repository: DemoMemberRepository(), makerspaceID: 7) + first.dismissGuidance(); first.invalidate() + let second = session.makeGuidedMemberState(repository: DemoMemberRepository(), makerspaceID: 8) + XCTAssertTrue(second.showGuidance); second.dismissGuidance(); second.invalidate() + XCTAssertFalse(session.makeGuidedMemberState(repository: DemoMemberRepository(), makerspaceID: 7).showGuidance) + XCTAssertFalse(session.makeGuidedMemberState(repository: DemoMemberRepository(), makerspaceID: 8).showGuidance) + } + func testReplayIsScopedToPersonaMakerspaceAndFeatureVersion() { + let session = DemoSession() + let first = session.makeGuidedMemberState(repository: DemoMemberRepository(), makerspaceID: 7) + first.dismissGuidance() + session.makeGuidedMemberState(repository: DemoMemberRepository(), makerspaceID: 8).dismissGuidance() + first.replayGuidance() + XCTAssertTrue(session.makeGuidedMemberState(repository: DemoMemberRepository(), makerspaceID: 7).showGuidance) + XCTAssertFalse(session.makeGuidedMemberState(repository: DemoMemberRepository(), makerspaceID: 8).showGuidance) + XCTAssertTrue(session.makeGuidedMemberState(repository: DemoMemberRepository(), makerspaceID: 8, featureVersion: "member-v2").showGuidance) + session.role = .staff + XCTAssertTrue(session.makeGuidedMemberState(repository: DemoMemberRepository(), makerspaceID: 8).showGuidance) + } + func testExitResetClearsMemoryAndCannotAlterAnotherDemoSession() { + let first = DemoSession(), other = DemoSession() + let retired = first.makeGuidedMemberState(repository: DemoMemberRepository(), makerspaceID: 7) + retired.dismissGuidance() + XCTAssertTrue(other.makeGuidedMemberState(repository: DemoMemberRepository(), makerspaceID: 7).showGuidance) + first.reset() + retired.dismissGuidance() + XCTAssertTrue(first.guidanceFlags.isEmpty) + XCTAssertTrue(first.makeGuidedMemberState(repository: DemoMemberRepository(), makerspaceID: 7).showGuidance) + } +} +#endif diff --git a/SpaceWorksTests/GuideNavigationTests.swift b/SpaceWorksTests/GuideNavigationTests.swift new file mode 100644 index 0000000..8fa760e --- /dev/null +++ b/SpaceWorksTests/GuideNavigationTests.swift @@ -0,0 +1,34 @@ +#if DEBUG +import XCTest +@testable import SpaceWorks + +@MainActor final class GuideNavigationTests: XCTestCase { + func testBookingsShortcutKeepsRetainedActivityHistoryAndUnsavedDrafts() async throws { + let navigation = MemberNavigation() + let repository = DemoMemberActionsRepository() + let drafts = MemberActionDrafts(repository: repository) + let spaces = try await DemoMemberRepository().spaces() + let space = try XCTUnwrap(spaces.first) + let booking = drafts.booking(space) + booking.start = booking.start.addingTimeInterval(900) + let editedDate = booking.start + await drafts.profile.load(); drafts.profile.displayName = "Unsaved maker" + navigation.path(.activity).wrappedValue = [.space(space.public_token)] + navigation.path(.profile).wrappedValue = [.contact] + navigation.openBookings() + XCTAssertEqual(navigation.selected, .activity) + XCTAssertEqual(navigation.paths[.activity], [.space(space.public_token), .bookings]) + XCTAssertEqual(navigation.paths[.profile], [.contact]) + XCTAssertTrue(drafts.booking(space) === booking) + XCTAssertEqual(booking.start, editedDate) + XCTAssertEqual(drafts.profile.displayName, "Unsaved maker") + } + func testBookingsShortcutDoesNotDuplicateAndScopeInvalidationClearsHistory() { + let navigation = MemberNavigation(); navigation.open(.event("event-token"), tab: .activity) + navigation.openBookings(); navigation.openBookings() + XCTAssertEqual(navigation.paths[.activity], [.event("event-token"), .bookings]) + navigation.invalidate() + XCTAssertTrue(navigation.paths.isEmpty); XCTAssertEqual(navigation.selected, .home) + } +} +#endif diff --git a/SpaceWorksTests/StaffCooldownTests.swift b/SpaceWorksTests/StaffCooldownTests.swift new file mode 100644 index 0000000..6852625 --- /dev/null +++ b/SpaceWorksTests/StaffCooldownTests.swift @@ -0,0 +1,46 @@ +import Foundation +import XCTest +import SpaceWorksAPI +@testable import SpaceWorks + +#if DEBUG +@MainActor final class StaffCooldownTests: XCTestCase { + func testHugeFiniteRetryDeadlineYieldsAndInvalidationCannotReopenGate() async throws { + let state = StaffState(repository: DemoStaffRepository(), makerspaceID: 7, actions: [], features: []) + await state.loadQueue() + XCTAssertFalse(state.requests.isEmpty) + var childPrivateValue = "sample pending scan" + state.registerChild { childPrivateValue = "" } + + let deadline = Date(timeIntervalSinceNow: 1e20) + XCTAssertTrue(deadline.timeIntervalSince1970.isFinite) + state.refuse(APIError(status: 429, code: "rate_limited", detail: "Try again later.", retryAfter: deadline)) + // Let the actual main-actor cooldown task begin its first cancellable chunk. + // The former direct UInt64 conversion traps here before it can suspend. + for _ in 0..<8 { await Task.yield() } + XCTAssertEqual(state.retryAfter, deadline) + XCTAssertFalse(state.canRequest) + XCTAssertTrue(state.isActive) + + let laterDeadline = Date(timeIntervalSinceNow: 2e20) + state.refuse(APIError(status: 429, code: "rate_limited", detail: "Try again later.", retryAfter: laterDeadline)) + state.refuse(APIError(status: 429, code: "rate_limited", detail: "Try again later.", retryAfter: Date(timeIntervalSinceNow: 60))) + for _ in 0..<8 { await Task.yield() } + XCTAssertEqual(state.retryAfter, laterDeadline, "Cancelled or shorter cooldowns cannot shorten the authoritative deadline.") + XCTAssertFalse(state.canRequest) + + state.invalidate() + for _ in 0..<8 { await Task.yield() } + await state.loadQueue() + XCTAssertEqual(childPrivateValue, "") + XCTAssertTrue(state.requests.isEmpty) + XCTAssertTrue(state.directLoans.isEmpty) + XCTAssertTrue(state.events.isEmpty) + XCTAssertTrue(state.spaces.isEmpty) + XCTAssertTrue(state.bookings.isEmpty) + XCTAssertFalse(state.isActive) + XCTAssertFalse(state.canRequest) + XCTAssertEqual(state.retryAfter, laterDeadline, "An invalidated timer cannot reopen this request gate.") + } +} +#endif diff --git a/SpaceWorksUITests/AccountFlowTests.swift b/SpaceWorksUITests/AccountFlowTests.swift index d1d04d8..d99735a 100644 --- a/SpaceWorksUITests/AccountFlowTests.swift +++ b/SpaceWorksUITests/AccountFlowTests.swift @@ -4,7 +4,7 @@ final class AccountFlowTests: XCTestCase { override func setUp() { continueAfterFailure = false; XCUIDevice.shared.orientation = .portrait } private func tap(_ element: XCUIElement, in app: XCUIApplication) { NativeUIActions.tap(element, in: app) } private func account(extra: [String] = [], withoutMakerspace: Bool = false) -> XCUIApplication { - let app = XCUIApplication(); app.launchArguments = ["--ui-testing"] + extra; app.launch() + let app = XCUIApplication(); app.launchArguments = ["--ui-testing"] + extra; app.launchForDeviceProfile() tap(app.buttons["explore-demo"], in: app); tap(app.buttons["Continue as member"], in: app) let username = app.textFields["demo-username"]; tap(username, in: app); username.typeText("member\n") app.secureTextFields["demo-password"].typeText("sample\n"); tap(app.buttons["demo-sign-in"], in: app) diff --git a/SpaceWorksUITests/GuideFlowTests.swift b/SpaceWorksUITests/GuideFlowTests.swift new file mode 100644 index 0000000..8c545aa --- /dev/null +++ b/SpaceWorksUITests/GuideFlowTests.swift @@ -0,0 +1,73 @@ +import XCTest + +final class GuideFlowTests: XCTestCase { + override func setUp() { continueAfterFailure = false; XCUIDevice.shared.orientation = .portrait } + private func tap(_ element: XCUIElement, in app: XCUIApplication) { NativeUIActions.tap(element, in: app) } + private func demo(staff: Bool = false) -> XCUIApplication { + let app = XCUIApplication(); app.launchArguments = ["--ui-testing"]; app.launchForDeviceProfile() + tap(app.buttons["explore-demo"], in: app) + if staff { tap(app.segmentedControls["demo-role"].buttons["Staff"], in: app) } + tap(app.buttons[staff ? "Continue as staff" : "Continue as member"], in: app) + let username = app.textFields["demo-username"]; tap(username, in: app); username.typeText(staff ? "staff\n" : "meera\n") + app.secureTextFields["demo-password"].typeText("sample\n") + tap(app.buttons["demo-sign-in"], in: app); tap(app.buttons["demo-space-0"], in: app) + XCTAssertTrue(app.staticTexts[staff ? "Welcome back, Arjun Rao." : "Welcome back, Meera Nair."].waitForExistence(timeout: 10)) + return app + } + private func tab(_ name: String, app: XCUIApplication) { + if app.tabBars.buttons[name].exists { tap(app.tabBars.buttons[name], in: app); return } + let target = app.descendants(matching: .any)["nav-\(name)"].firstMatch + if !target.isHittable { + let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'sidebar'")).firstMatch + if sidebar.exists { sidebar.tap() } + } + tap(target, in: app) + } + private func capture(_ name: String) { + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); attachment.name = name; attachment.lifetime = .keepAlways; add(attachment) + } + private func makerspace(_ name: String, app: XCUIApplication) { + let switcher = app.buttons["demo-makerspace-switcher"] + tap(switcher, in: app); tap(app.buttons[name], in: app) + let changed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", name), object: switcher) + XCTAssertEqual(XCTWaiter.wait(for: [changed], timeout: 5), .completed) + } + func testMemberGuideReplaysIntroductionAndRestoresDismissedTips() { + let app = demo(); tap(app.buttons["guidance-dismiss"], in: app) + XCTAssertFalse(app.buttons["guidance-dismiss"].exists) + makerspace("Maker Space Bengaluru", app: app) + tap(app.buttons["guidance-dismiss"], in: app) + makerspace("Fab Lab Kochi", app: app) + XCTAssertFalse(app.buttons["guidance-dismiss"].exists, "Returning to a makerspace must preserve its dismissed tip") + tab("Profile", app: app); tap(app.buttons["app-guide"], in: app) + XCTAssertFalse(app.descendants(matching: .any)["guide-staff"].firstMatch.exists) + tap(app.descendants(matching: .any)["guide-borrow"].firstMatch, in: app) + XCTAssertTrue(app.staticTexts["Open Inventory, search or filter, then choose an item. Add the items you need and review quantities before submitting your request. Staff confirm availability, collection and borrowing terms."].exists) + capture("Member app guide") + tap(app.buttons["guide-reset-tips"], in: app) + XCTAssertTrue(app.staticTexts["guide-tips-reset"].waitForExistence(timeout: 5)) + tap(app.buttons["guide-replay-introduction"], in: app) + XCTAssertTrue(app.buttons["Skip introduction"].waitForExistence(timeout: 5)); capture("Introduction replay from app guide") + tap(app.buttons["Skip introduction"], in: app) + tap(app.buttons["guide-done"], in: app); tab("Home", app: app) + XCTAssertTrue(app.buttons["guidance-dismiss"].waitForExistence(timeout: 5)) + tab("Activity", app: app); tap(app.buttons["Find a space"], in: app) + tap(app.descendants(matching: .any)["space-00000000-0000-0000-0000-000000000031"].firstMatch, in: app) + XCTAssertTrue(app.navigationBars["Book a space"].waitForExistence(timeout: 5)) + tab("Home", app: app); tap(app.buttons["View bookings"], in: app) + XCTAssertTrue(app.navigationBars["Bookings"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.buttons["Workshop bench"].exists) + capture("Start here opens bookings above retained activity draft") + tab("Home", app: app) + makerspace("Maker Space Bengaluru", app: app) + XCTAssertFalse(app.buttons["guidance-dismiss"].exists, "Replay affects only its owning makerspace") + } + func testStaffGuideExplainsEvidenceAndExplicitConfirmation() { + let app = demo(staff: true); tab("Profile", app: app); tap(app.buttons["app-guide"], in: app) + let section = app.descendants(matching: .any)["guide-staff"].firstMatch + tap(section, in: app) + XCTAssertTrue(app.staticTexts["Scanning finds a record; it never confirms handover or attendance by itself. Check the resolved person, then choose Confirm check-in. If access changes or a conflict appears, refresh before continuing."].exists) + capture("Staff guide and camera explanation") + XCTAssertFalse(app.alerts.firstMatch.exists) + } +} diff --git a/SpaceWorksUITests/MainFlowTests.swift b/SpaceWorksUITests/MainFlowTests.swift index 169396a..b22f514 100644 --- a/SpaceWorksUITests/MainFlowTests.swift +++ b/SpaceWorksUITests/MainFlowTests.swift @@ -5,7 +5,7 @@ final class MainFlowTests: XCTestCase { private func launch(extra: [String] = []) -> XCUIApplication { let app = XCUIApplication() app.launchArguments = ["--ui-testing"] + extra - app.launch() + app.launchForDeviceProfile() return app } private func connect(_ app: XCUIApplication) { @@ -36,7 +36,7 @@ final class MainFlowTests: XCTestCase { XCTAssertTrue(item.waitForExistence(timeout: 5)); item.tap() } func testPasswordMakerspaceSwitchProfileAndSignOut() { - let app = launch(); connect(app); attach(app, "Sign in portrait") + let app = launch(); connect(app); attach(app, "Sign in initial device layout") credentials(app); selectSpace(app) XCTAssertTrue(app.staticTexts["Welcome back, Maker."].waitForExistence(timeout: 10)) attach(app, "Home member portrait") @@ -64,7 +64,7 @@ final class MainFlowTests: XCTestCase { attach(app, "Server failure") } func testDarkModeLargestTextPortraitAndLandscape() { - let app = launch(extra: ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]) + let app = launch(extra: ["--ui-start-light", "--ui-start-portrait", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]) app.buttons["appearance"].tap(); connect(app) app.swipeUp(); attach(app, "Sign in largest text dark portrait") XCUIDevice.shared.orientation = .landscapeLeft @@ -97,7 +97,7 @@ final class MainFlowTests: XCTestCase { XCTAssertTrue(app.staticTexts["Borrow with confidence."].exists) app.buttons["intro-next"].tap(); app.buttons["intro-next"].tap() XCTAssertTrue(app.textFields["server-url"].exists) - app.terminate(); app.launchArguments = ["--ui-testing", "--introduction"]; app.launch() + app.terminate(); app.launchArguments = ["--ui-testing", "--introduction"]; app.launchForDeviceProfile() XCTAssertTrue(app.textFields["server-url"].waitForExistence(timeout: 10)) XCTAssertFalse(app.buttons["Skip introduction"].exists) } diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index 891a735..bb0af6e 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -124,7 +124,26 @@ final class MemberActionsFlowTests: XCTestCase { } return viewport.insetBy(dx: gestureInset, dy: gestureInset) } - private func drag(_ scroll: XCUIElement, _ app: XCUIApplication, target: XCUIElement, down: Bool) { + /// This landscape proof requires the whole primary label, without activating it. + private func revealFully(_ element: XCUIElement, in app: XCUIApplication) { + for _ in 0..<24 { + let scroll = containingScroll(element, app) + let viewport = visibleContent(scroll, app, gestureInset: 0).insetBy(dx: 2, dy: 2) + if element.exists { + let frame = element.frame + if frame.minX.isFinite, frame.minY.isFinite, frame.maxX.isFinite, frame.maxY.isFinite, + frame.width > 0, frame.height > 0, viewport.contains(frame), + element.isEnabled, element.isHittable { return } + drag(scroll, app, target: element, down: frame.minY < viewport.minY, approachTargetEdge: true) + } else { + drag(scroll, app, target: element, down: false, approachTargetEdge: true) + } + } + capture("Primary action not fully visible in largest text landscape") + XCTFail("The complete enabled primary action must fit inside the usable landscape viewport.") + } + private func drag(_ scroll: XCUIElement, _ app: XCUIApplication, target: XCUIElement, down: Bool, + approachTargetEdge: Bool = false) { let viewport = visibleContent(scroll, app) guard viewport.width > 20, viewport.height > 30 else { return } // Drag entirely inside the actual visible content, never across tab/navigation bars. @@ -132,14 +151,14 @@ final class MemberActionsFlowTests: XCTestCase { // viewport. Hold at a slow, bounded endpoint so the next visible target is // examined before momentum can carry it under the navigation/tab bars. var distance = min(200, viewport.height * 0.4) - if scroll.identifier == "request-review-scroll", target.exists { + if (scroll.identifier == "request-review-scroll" || approachTargetEdge), target.exists { let frame = target.frame if frame.minY.isFinite, frame.maxY.isFinite, frame.height > 0, frame.height <= viewport.height - 4 { let inset = viewport.insetBy(dx: 2, dy: 2) let remaining = down ? inset.minY - frame.minY : frame.maxY - inset.maxY if remaining > 0 { // Native traces move 190pt for a 200pt drag (10pt pan slop). - // Approach the exposed edge precisely in short landscape content. + // Approach the exposed edge precisely for review and reveal-only landscape proof. distance = min(distance, max(12, remaining + 10)) } } @@ -171,7 +190,7 @@ final class MemberActionsFlowTests: XCTestCase { withVelocity: .slow, thenHoldForDuration: 0.3) } private func demo(_ arguments: [String] = []) -> XCUIApplication { - let app = XCUIApplication(); app.launchArguments = ["--ui-testing"] + arguments; app.launch() + let app = XCUIApplication(); app.launchArguments = ["--ui-testing"] + arguments; app.launchForDeviceProfile() tap(app.buttons["explore-demo"], in: app); tap(app.buttons["Continue as member"], in: app) let username = app.textFields["demo-username"]; tap(username, in: app) XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5)); username.typeText("meera\n") @@ -251,7 +270,7 @@ final class MemberActionsFlowTests: XCTestCase { capture("Waitlisted event without a ticket") } func testLargestTextDarkRequestReviewPortraitAndLandscape() { - let app = demo(["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]) + let app = demo(["--ui-start-light", "--ui-start-portrait", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]) tap(app.buttons["appearance"], in: app) tap(app.buttons["Browse inventory"], in: app) tap(app.descendants(matching: .any)["product-1"].firstMatch, in: app) @@ -259,7 +278,11 @@ final class MemberActionsFlowTests: XCTestCase { tap(app.buttons["submit-request"], in: app) XCTAssertTrue(app.staticTexts["Request received"].waitForExistence(timeout: 5)); capture("Request receipt largest text dark portrait") XCUIDevice.shared.orientation = .landscapeLeft; XCTAssertGreaterThan(app.frame.width, app.frame.height) - tap(app.buttons["Done"], in: app); capture("Member item largest text dark landscape") + tap(app.buttons["Done"], in: app) + let add = app.buttons["Add to request"] + revealFully(add, in: app) + XCTAssertGreaterThanOrEqual(add.frame.height, 48) + capture("Member item primary action fully visible largest text dark landscape") } } diff --git a/SpaceWorksUITests/MemberFlowTests.swift b/SpaceWorksUITests/MemberFlowTests.swift index be8c902..7671fd3 100644 --- a/SpaceWorksUITests/MemberFlowTests.swift +++ b/SpaceWorksUITests/MemberFlowTests.swift @@ -5,7 +5,7 @@ final class MemberFlowTests: XCTestCase { private func demo(_ argument: String? = nil) -> XCUIApplication { let app = XCUIApplication() app.launchArguments = ["--ui-testing"] + (argument.map { [$0] } ?? []) - app.launch() + app.launchForDeviceProfile() app.buttons["explore-demo"].tap(); app.buttons["Continue as member"].tap() app.swipeUp(); app.textFields["demo-username"].tap(); app.textFields["demo-username"].typeText("meera\n") app.secureTextFields["demo-password"].typeText("sample\n") diff --git a/SpaceWorksUITests/NativeTestLaunch.swift b/SpaceWorksUITests/NativeTestLaunch.swift new file mode 100644 index 0000000..10c40cf --- /dev/null +++ b/SpaceWorksUITests/NativeTestLaunch.swift @@ -0,0 +1,40 @@ +import XCTest +import UIKit + +/// UI-test runner policy. It never enters the application or its Release target. +extension XCUIApplication { + func launchForDeviceProfile() { + let profiles: Set = ["compact", "phone", "large", "tablet", "tablet-landscape", "large-tablet"] + let profile = ProcessInfo.processInfo.environment["SPACEWORKS_TEST_PROFILE"] ?? "phone" + XCTAssertTrue(profiles.contains(profile), "The test scheme must receive a supported matrix profile.") + XCTAssertTrue(launchArguments.contains("--ui-testing"), "Profile launches require isolated Debug fixtures.") + let dark = !launchArguments.contains("--ui-start-light") && ["large", "tablet-landscape"].contains(profile) + let landscape = !launchArguments.contains("--ui-start-portrait") && profile == "tablet-landscape" + let reduceMotion = profile == "large-tablet" + launchEnvironment["SPACEWORKS_TEST_APPEARANCE"] = dark ? "dark" : "light" + launchEnvironment["SPACEWORKS_TEST_REDUCE_MOTION"] = reduceMotion ? "1" : "0" + XCUIDevice.shared.orientation = landscape ? .landscapeLeft : .portrait + launch() + let orientation = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in + landscape ? self.frame.width > self.frame.height : self.frame.height > self.frame.width + }, object: nil) + XCTAssertEqual(XCTWaiter.wait(for: [orientation], timeout: 10), .completed, "Requested launch orientation must actually be visible.") + let appearance = buttons["appearance"] + XCTAssertTrue(appearance.waitForExistence(timeout: 10)) + XCTAssertEqual(appearance.label, dark ? "Light mode" : "Dark mode", "The app's explicit theme must match its matrix profile.") + let record: [String: Any] = [ + "profile": profile, "app_appearance": dark ? "dark" : "light", + "orientation": landscape ? "landscape" : "portrait", + "window_width_points": frame.width, "window_height_points": frame.height, + "reduce_motion_environment": reduceMotion, + ] + if let data = try? JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]), + let value = String(data: data, encoding: .utf8) { + XCTContext.runActivity(named: "Verified initial app profile") { activity in + let attachment = XCTAttachment(string: value) + attachment.name = "Initial app profile"; attachment.lifetime = .keepAlways + activity.add(attachment) + } + } + } +} diff --git a/SpaceWorksUITests/NotificationsFlowTests.swift b/SpaceWorksUITests/NotificationsFlowTests.swift index 1fb876e..21f07ab 100644 --- a/SpaceWorksUITests/NotificationsFlowTests.swift +++ b/SpaceWorksUITests/NotificationsFlowTests.swift @@ -14,7 +14,7 @@ final class NotificationsFlowTests: XCTestCase { tap(item, app) } private func notifications() -> XCUIApplication { - let app = XCUIApplication(); app.launchArguments = ["--ui-testing"]; app.launch() + let app = XCUIApplication(); app.launchArguments = ["--ui-testing"]; app.launchForDeviceProfile() tap(app.buttons["explore-demo"], app); tap(app.buttons["Continue as member"], app) let username = app.textFields["demo-username"]; tap(username, app); username.typeText("meera\n") app.secureTextFields["demo-password"].typeText("sample\n") diff --git a/SpaceWorksUITests/PaymentsFlowTests.swift b/SpaceWorksUITests/PaymentsFlowTests.swift index 4a243de..a01d557 100644 --- a/SpaceWorksUITests/PaymentsFlowTests.swift +++ b/SpaceWorksUITests/PaymentsFlowTests.swift @@ -5,7 +5,7 @@ final class PaymentsFlowTests: XCTestCase { override func setUp() { continueAfterFailure = false; XCUIDevice.shared.orientation = .portrait } private func tap(_ element: XCUIElement, in app: XCUIApplication) { NativeUIActions.tap(element, in: app) } private func member() -> XCUIApplication { - let app = XCUIApplication(); app.launchArguments = ["--ui-testing"]; app.launch() + let app = XCUIApplication(); app.launchArguments = ["--ui-testing"]; app.launchForDeviceProfile() tap(app.buttons["explore-demo"], in: app); tap(app.buttons["Continue as member"], in: app) let username = app.textFields["demo-username"]; tap(username, in: app); username.typeText("member\n") app.secureTextFields["demo-password"].typeText("sample\n") diff --git a/SpaceWorksUITests/SecurityFlowTests.swift b/SpaceWorksUITests/SecurityFlowTests.swift index e2b0202..af1166f 100644 --- a/SpaceWorksUITests/SecurityFlowTests.swift +++ b/SpaceWorksUITests/SecurityFlowTests.swift @@ -3,7 +3,7 @@ import XCTest final class SecurityFlowTests: XCTestCase { override func setUp() { continueAfterFailure = false; XCUIDevice.shared.orientation = .portrait } func testIsolatedEmailVerificationPreviewContinuesToMakerspaceSelection() { - let app = XCUIApplication(); app.launchArguments = ["--ui-testing"]; app.launch() + let app = XCUIApplication(); app.launchArguments = ["--ui-testing"]; app.launchForDeviceProfile() NativeUIActions.tap(app.buttons["explore-demo"], in: app); NativeUIActions.tap(app.buttons["Continue as member"], in: app) let username = app.textFields["demo-username"]; NativeUIActions.tap(username, in: app); username.typeText("unverified\n") app.secureTextFields["demo-password"].typeText("sample\n"); NativeUIActions.tap(app.buttons["demo-sign-in"], in: app) @@ -21,7 +21,7 @@ final class SecurityFlowTests: XCTestCase { XCTAssertTrue(app.buttons["demo-space-0"].waitForExistence(timeout: 10)) } func testRequiredPasswordUpdateReturnsToFreshSignIn() { - let app = XCUIApplication(); app.launchArguments = ["--ui-testing", "--temporary-password"]; app.launch() + let app = XCUIApplication(); app.launchArguments = ["--ui-testing", "--temporary-password"]; app.launchForDeviceProfile() let server = app.textFields["server-url"]; NativeUIActions.tap(server, in: app); server.typeText("https://ui-test.spaceworks.invalid\n") XCTAssertTrue(app.textFields["username"].waitForExistence(timeout: 10)) NativeUIActions.tap(app.textFields["username"], in: app); app.textFields["username"].typeText("maker\n") diff --git a/SpaceWorksUITests/StaffFlowTests.swift b/SpaceWorksUITests/StaffFlowTests.swift index 46a1478..9bf1940 100644 --- a/SpaceWorksUITests/StaffFlowTests.swift +++ b/SpaceWorksUITests/StaffFlowTests.swift @@ -4,7 +4,7 @@ final class StaffFlowTests: XCTestCase { override func setUp() { continueAfterFailure = false; XCUIDevice.shared.orientation = .portrait } private func tap(_ element: XCUIElement, in app: XCUIApplication) { NativeUIActions.tap(element, in: app) } private func staff(extra: [String] = []) -> XCUIApplication { - let app = XCUIApplication(); app.launchArguments = ["--ui-testing"] + extra; app.launch() + let app = XCUIApplication(); app.launchArguments = ["--ui-testing"] + extra; app.launchForDeviceProfile() tap(app.buttons["explore-demo"], in: app) tap(app.segmentedControls["demo-role"].buttons["Staff"], in: app) tap(app.buttons["Continue as staff"], in: app) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3f551e3..7e07179 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,47 @@ # Phase notes +## A8 Usage guide and release handoff — implementation checkpoint (2026-10-17) + +This isolated A8 candidate is based on A7 `d05dcfc477dae19bd2acd78201f783c9d27cfd61`. A7 native acceptance remains pending [workflow 39017485631](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/39017485631); this preparation does not claim its result or bypass that gate. The A8 target is 294 native cases and 36 UI journeys, pending actual compiler discovery and the full matrix. Local checks passed 40 Python cases and the 779-key English catalog check. + +Add a replayable App guide covering navigation, requests, renewal, bookings, event tickets, +notifications, payments, security and membership. Staff instructions appear only with available +staff tools. Keep introduction replay and account/makerspace-scoped helpful tips, and use an enabled +Profile alternative when the booking module is unavailable. Show the actual app version/build, +validated selected server where available, and only configured support/privacy destinations. +Unrepresentably long server cooldowns now have safe localized display copy without changing the +retained deadline or permitting an early request; three boundary tests cover that conversion. + +White-label builds can supply paired native logo PNGs, a custom SVG icon and a semantic palette. +Validate selected files and optional HTTPS links before generated writes, preserve supplied artwork +bytes and the pinned Design masters, and select custom artwork even with a changed app name. The +regressions exercise actual custom configuration rather than requiring default-brand output. + +The final workflow supplies real app appearance/orientation policies to every UI journey, includes +compact/normal/large iPhone and three iPad profiles, and preserves all native tests. The unsigned +physical-device build is audited for effective identity, device families/orientations, camera copy, +branding/fonts/icons, English resources, privacy manifest and disabled multiple scenes. Profile +attachments record actual window/theme; reduced-motion app-environment coverage is distinguished +from a physical system-setting or third-party SDK test. + +Provide concrete store text, phone/tablet screenshot and reviewer-account plans, white-label setup, +privacy/account controls and the release procedure. Native test evidence and accepted SHA belong to +the final dev-to-main pull request's exact workflow links. Signing, real-device attestation/providers, +APNs/link deployment, payment confirmation, whole-account deletion and TestFlight/store submission +remain owner/backend release gates; Debug previews and unsigned builds do not establish those results. + +Source adds two guide UI journeys, three demo guidance scope/reset cases and three countdown boundary cases. Local composed checks pass +40 Python cases, English catalog consistency and source lint; the native compiler/full device matrix +is the acceptance gate. Parity: App guide / Replay introduction / Reset helpful tips / Start here. + + +The View bookings shortcut opens a typed current-makerspace booking list above retained Activity history, preserving cached booking/profile drafts. Two state regressions cover history/draft retention and invalidation; the existing guide journey also exercises the real shortcut. + +Staff retry timers retain even very large finite server deadlines while sleeping in cancellable +chunks of at most 3600 seconds. This avoids an overflowing UInt64 nanosecond conversion and keeps +canceled or superseded timers from reopening requests. One state regression exercises extreme +server deadlines, replacement and private-scope invalidation; native execution is still required. + ## A7 Account and localization — implementation checkpoint (2026-10-17) Provide global Account and security before joining a makerspace: authenticator status, phone linking, diff --git a/docs/RELEASE-READINESS.md b/docs/RELEASE-READINESS.md new file mode 100644 index 0000000..e4554d6 --- /dev/null +++ b/docs/RELEASE-READINESS.md @@ -0,0 +1,147 @@ +# iOS release handoff + +Prepared 16 October 2026. This is the release procedure and evidence ledger, not a claim of +store submission. Phase implementation and passing checks are recorded in `docs/CHANGELOG.md`. +The owner requested phones and tablets and deferred backend setup; that instruction supersedes +the original PRD's tablet exclusion and intermediate merge requirement. Work stays on `dev` +until the owner reviews the final `dev` to `main` pull request. + +## Reproduce the app + +Clone recursively and retain the reviewed `design/` commit. The authoritative contract is +`api/openapi.json`; do not substitute a schema from a local server checkout. Use Xcode 26.2 and +its iOS 26 SDKs, matching the CI pin. The pinned Stripe package uses UIKit APIs absent from the +Xcode 16.4 SDK. Select the correct installation through `DEVELOPER_DIR` or `xcode-select`. +On a Mac: + +```sh +brew install xcodegen librsvg +python3 scripts/generate.py +python3 -m unittest discover -s scripts/tests -v +python3 scripts/lint.py +xcodegen generate +export GITHUB_ENV="$(mktemp)" +python3 scripts/device-matrix.py phone +SIMULATOR_ID="$(python3 -c 'import json; print(json.load(open("validation-device.json"))["udid"])')" +xcodebuild test -project SpaceWorks.xcodeproj -scheme SpaceWorks \ + -destination "platform=iOS Simulator,id=$SIMULATOR_ID" \ + -derivedDataPath DerivedData -resultBundlePath TestResults.xcresult \ + -skipPackagePluginValidation ONLY_ACTIVE_ARCH=YES CODE_SIGN_STYLE=Manual \ + CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= +``` + +`device-matrix.py` writes to `GITHUB_ENV` in CI. The temporary-file assignment above supports a +local Mac invocation; omit it when running inside GitHub Actions. +The generated `LOCALCI` Keychain identity exists only for simulator tests. It must never +replace the Apple team identity or physical-device provisioning. + +The `iOS` GitHub Actions workflow runs representative phone/tablet validation on pushes/PRs. +Dispatch it manually on the final commit to run compact/normal/large phone, iPad mini, +landscape iPad and large iPad profiles. Keep `validation-device.json`, test result bundles, +screenshots, logs and the dependency resolution record with the release evidence. Check actual +device/runtime names rather than claiming every iPhone/iPad was tested. The phone job also +compiles an unsigned physical-device Release build; that is compile evidence, not an installable +store build, a signed archive, or a real-device SDK test. + +## Configure one deployment + +`branding/brand.json` owns the display name, `ios_bundle_id`, `ios_team_id`, icon and semantic +theme path. `server_url: null` leaves server selection available; an HTTPS origin locks the +build to that deployment. Provide real HTTPS support and privacy URLs before distribution. +See `docs/WHITE-LABEL.md` for generator mechanics. Do not hand-edit generated assets, +`Brand.xcconfig`, generated API sources or the generated Xcode project. + +Use an owner-controlled registered bundle ID and team. Configure the distribution certificate +and provisioning profile in the private signing environment. Verify the archive's effective +bundle ID, team, entitlements, capabilities, supported device family, version/build number, +icon, privacy resources and URL schemes. The current source version is controlled by +`MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in `project.yml`; increment the build +number for each uploaded build. Never commit a certificate, private key, provisioning profile, +App Store Connect API key, APNs key, test password or service credential. + +| Integration | Deployment input and verification | +|---|---| +| App Attest | Enable the capability for the real team/bundle ID; server must allow that app and validate the documented proof. Development and production environments must match the signed build. Test challenge, proof, refusal and fresh sign-in on real hardware. | +| Google | Registered iOS OAuth ID, server audience and reversed callback scheme must agree. `google_reversed_client_id`, when supplied to the brand generator, is the registered scheme. A web-only config response does not establish a supported native device-grant flow. | +| Apple | Enable Sign in with Apple, configure the correct native audience and use a supported server device-grant response. Test cancellation, repeat sign-in, revoked consent and account deletion cleanup when implemented. | +| Organization OIDC | Configure the exact registered callback and native delivery contract. Use the ephemeral native system authorization session; never substitute a web cookie session. | +| APNs | Owner supplies Push capability/provisioning and the private APNs provider key to the server. Verify production/sandbox topic, device registration, token change, logout deletion and foreground/cold-launch routing. | +| Universal links | Server publishes its real associated-domain file; app entitlement must match the owner's domain/team/app. Verify a link from outside the app, then authorization and fresh resource loading. A custom scheme alone is not universal-link verification. | +| Camera/evidence | Archive includes the purpose string; test first request, denial, later Settings enablement, no hardware, rotation, cancellation and manual entry. Upload only explicitly confirmed evidence through documented operations. | +| Payments | Server selects vendor and supplies runtime public checkout values; secrets remain on the server. Verify configured sandbox success, cancellation, failure, delayed webhook and later status refresh. SDK success alone never proves paid status. | + +The repository backend-request issue tracks the missing contract work: +[native backend dependencies](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/issues/3). +Native minimum-version/provider configuration, staff device authority, push v2/link files, +Razorpay checkout fields and whole-account deletion need the owner's supported contracts. +Account management also has confirmed native limitations: the current backend refuses optional +authenticator enrollment from a device session and provider linking from a native client. Native +login enrollment challenges remain supported. Account session/export list schemas omit pagination +parameters; later pages must wait for a published contract. The app must keep these live paths +explicitly unavailable and never impersonate a browser client. Required-MFA disable controls stay +hidden; recovery-code replacement and supported MFA changes clear old sessions before returning +to sign-in. A wrong management password must not destroy a separately verified valid session. +Debug demo journeys exercise UI states with labelled in-memory data; they do not validate +attestation, authentication delivery, push, charges or deletion against a real service. + +## Privacy and store content + +The owner must confirm actual production collection, sharing, retention and SDK behavior before +answering App Store Connect privacy questions. Review the final archive, including all transitive +SDK privacy manifests and required-reason API usage. Do not declare that the app collects no data +because it has no analytics. Apple requires disclosure of relevant partner SDK practices as well +as the app's practices. [App privacy](https://developer.apple.com/help/app-store-connect/manage-app-information/manage-app-privacy), +[privacy manifests](https://developer.apple.com/documentation/bundleresources/adding-a-privacy-manifest-to-your-app-or-third-party-sdk), +[SDK requirements](https://developer.apple.com/support/third-party-SDK-requirements/). + +| Area to verify | Production question to resolve | +|---|---| +| Account/contact | Name, email, optional phone, account identifiers and membership information: purpose, linkage, sharing and retention. | +| Device/security | Device grant, attestation and push identifiers: destinations, security purpose and deletion behavior. | +| Camera/photos | Whether captured evidence leaves the device, who can view it, storage duration and removal rules. QR scanning alone is distinct from photo upload. | +| Activity/payments | Requests, loans, bookings, event attendance, payment metadata and legally retained records; provider-specific processing. | +| Diagnostics | Verify actual SDK defaults and server logs; no app analytics was authorized. Do not assume dependencies never transmit diagnostics. | + +Account creation requires a genuine in-app way to initiate whole-account deletion. A membership +leave action, disabled placeholder or demo completion does not satisfy that requirement. The +owner-deferred deletion contract remains a release gate; implement the PRD's blocker list, +fix links, reauthentication, confirmation and anonymized retained-record explanation when supplied. +[Apple account deletion guidance](https://developer.apple.com/help/app-review/guideline-reference/5-1-1-account-deletion). + +Supply the real support/privacy URLs, description, subtitle, category, age/content answers, +device-specific screenshots, review notes and a stable reviewer account/makerspace. Suggested +description to adapt: “Find tools, request equipment, book a space and keep up with your makerspace. +See loans, events, tickets, payments and updates together in Space Works.” Include only features +enabled and working in the submitted deployment. Keep reviewer credentials in App Store Connect's +private review information, never the repository. Explain any hardware-dependent review path. + +Concrete listing copy, screenshot capture order and private reviewer-access notes are prepared +in [STORE-LISTING.md](STORE-LISTING.md). + +## Final acceptance and distribution + +1. Record the exact app commit, Design commit, schema hash, dependency versions, CI run links and + tested OS/hardware profiles. Require all phase checks to pass without skipped required tests. +2. Inspect the final app at large text, light/dark, portrait/landscape and narrow tablet windows. + Verify VoiceOver order/labels, keyboard navigation/autofill, reduced motion, clear errors, + static undistorted logos and reachable controls. Screenshot review complements functional tests. +3. On owner-provisioned devices verify real attestation/sign-in/refresh, providers, push, camera, + payment reconciliation, deletion and account/makerspace isolation. Record unsupported cases + explicitly rather than treating simulator/demo results as physical-device evidence. +4. Generate the project in the private Mac signing environment, archive the Release scheme for + a generic iOS device, validate the signed archive in Xcode Organizer, then upload it to the + owner's App Store Connect application. Simulator/unsigned CI outputs cannot be uploaded. +5. Use TestFlight first. Keep the production-compatible backend and reviewer account available, + complete internal/external tester review as applicable, resolve feedback, and submit the + accepted build with the matching privacy declarations and screenshots. + +Record distribution evidence here after owner provisioning; no credentials belong in this ledger: + +| Evidence | Value | +|---|---| +| Accepted app / Design commits | The exact tested source SHA and pinned Design input belong in the final [dev to main PR](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/pull/2) and matching CI artifacts; acceptance requires its completed validation section | +| Full device-matrix CI run | The final PR validation section must link the accepted six-profile run and reviewed artifacts; a pending or failed run does not establish acceptance | +| Signed archive / version / build | Owner signing environment required | +| Real-device provider test record | Owner backend/configuration required | +| Whole-account deletion verification | Updated owner contract required | +| TestFlight build / tester acceptance | Owner App Store Connect access required | diff --git a/docs/STORE-LISTING.md b/docs/STORE-LISTING.md new file mode 100644 index 0000000..342a39d --- /dev/null +++ b/docs/STORE-LISTING.md @@ -0,0 +1,110 @@ +# App Store listing draft + +Prepared 17 October 2026 for owner review and later deployment. The backend and store +provisioning remain deferred. Use this copy only for features verified in the submitted build. + +## Product fields + +| Field | Proposed value | Count / limit | +|---|---|---| +| Name | Space Works | 11 / 30 | +| Subtitle | Your makerspace, together | 25 / 30 | +| Promotional text | Find tools, request equipment, book a space and keep up with your makerspace. | 77 / 170 | +| Keywords | `tools,equipment,workshop,booking,events,membership,inventory` | 60 / 100 bytes | +| Primary category | Productivity, subject to owner deployment classification | — | + +Apple documents name/subtitle limits in +[App information](https://developer.apple.com/help/app-store-connect/reference/app-information/app-information) +and promotion, description, keyword and private review fields in +[Platform version information](https://developer.apple.com/help/app-store-connect/reference/app-information/platform-version-information). +The full-description limit is 4,000 characters; the draft below is within it. + +## Full description + +Plain text, 1388 characters: + +```text +Make more time for making. Space Works brings your makerspace's tools, spaces and activity together. + +Find what you need +Browse inventory, search and filter equipment, then review the items and quantities before sending a request. Follow updates from your makerspace and check your active loans and due dates. Request an eligible renewal when you need more time. + +Plan your next visit +Explore bookable spaces, review available slots and follow your booking's current status. Discover events, register and see whether your place is confirmed, awaiting approval or waitlisted. Open a valid event ticket when it is available. + +Keep up to date +Read makerspace updates in Inbox. Choose optional notification and communication preferences, and keep important safety messages available. + +Stay on top of your account +Where enabled, review charges, check payment status and open receipts or credit notes. Manage account security and signed-in devices, and review the membership controls your makerspace makes available. + +Space Works adapts to phones and tablets, with light and dark appearance and an app guide you can revisit at any time. + +You need a compatible makerspace server and an account. Member features require the relevant makerspace membership. Available tools, services and actions depend on your makerspace. Internet access is required for current availability and protected actions. +``` + +This is the base member listing. Do not add unsupported native provider, offline-cache, +whole-account deletion or live staff claims. Add the following staff paragraph only after the +owner's native authority contract and the submitted deployment actually enable those controls: + +```text +Authorized staff can review requests and approvals, check items before handover or return, and confirm attendance. Scanning finds a record; staff review and confirm each action. +``` + +## Screenshot capture plan + +| Order | Screen / message | Capture conditions | +|---|---|---| +| 1 | Home — your makerspace at a glance | Synthetic reviewer member; real supported deployment; no personal notices | +| 2 | Inventory — find what you need | Search/filter and an actual available item; no invented availability | +| 3 | Review a request | Real supported request review; avoid submitting a mutation just to obtain a screenshot | +| 4 | Bookings or events | Use the module actually enabled; show its genuine pending/confirmed state | +| 5 | App guide and Profile | Show readable navigation/help; exclude secrets and private account identifiers | +| 6 | Tablet navigation | Repeat Home/Inventory on a tablet, showing the actual adaptive layout | + +Use accepted CI full-OS screenshots to select composition and verify safe areas. Their Debug +demo banners identify fixture evidence; they are not production-store screenshots. Capture the +final submitted build against the owner's supported backend with a synthetic reviewer account. +Retain original dimensions and current console device slots; never stretch a phone image into +a tablet image. Keep recovery codes, verification codes and QR payloads out of store assets; +do not weaken secure-window protections to capture them. Optional staff images must show a +verified authorized staff deployment. The final PR links the accepted layout-proof artifacts. + +## Reviewer access and review notes + +The in-app **Explore demo** is a Debug-only, in-memory preview. Its sample credentials do not +authenticate the signed Release build. The owner-deferred backend must provide a stable real +reviewer account and makerspace before submission. Store credentials only in the private console +app-access/review fields, never in this repository. + +| Private console input | Owner-provisioned value | +|---|---| +| Server | Working HTTPS origin matching this build's deployment configuration | +| Member account | Working username/email and password entered privately | +| Membership | Active synthetic reviewer makerspace with representative safe records | +| Additional security | Working review access for the actual deployed MFA/provider policy | +| Optional staff account | Separate authorized reviewer only if staff is included in the submitted release | +| Review contact | Owner-managed name, email and phone entered privately | + +Review-note draft (replace bracketed non-secret deployment details in the private console): + +```text +Space Works connects members to their makerspace. +Server: [owner HTTPS origin; the build may already lock this origin] +Sign in with the reviewer credentials provided in the private app-access fields. +Select [reviewer makerspace] if prompted. +Use Home, Inventory, Activity, Inbox and Profile to explore the enabled member flows. +Profile > App guide explains the app and can replay the introduction. +Notifications and camera are optional and requested in their relevant tasks. +Use [owner-approved sandbox payment procedure] only if payments are enabled for review. +[Add separate authorized staff steps only when the submitted deployment supports them.] +``` + +## Deployment fields before submission + +Use real owner HTTPS `support_url` and `privacy_url` in `branding/brand.json` and the console. +They are currently unset; no contact address or legal policy is fabricated. Complete the real +Data safety/App privacy answers, deletion resource, content/age declarations, copyright and +owner signing details against the submitted binary and backend. The release handoff lists the +live backend/provider and distribution checks still required. This document prepares copy and +review steps; no store upload, closed test or TestFlight distribution is claimed. diff --git a/docs/WHITE-LABEL.md b/docs/WHITE-LABEL.md index 8f10128..02e7095 100644 --- a/docs/WHITE-LABEL.md +++ b/docs/WHITE-LABEL.md @@ -1,15 +1,31 @@ # Publish your own iOS build -This guide prepares a build for one deployment. A0 supplies the base; store release follows A7/A8. +This guide prepares a build for one deployment. Use [RELEASE-READINESS.md](RELEASE-READINESS.md) for the complete build/test commands, deployment inputs, privacy review and distribution evidence. The owner's batch workflow permits tested phases on `dev` before a final reviewed merge. -1. Fork this repo and clone recursively (`git clone --recurse-submodules …`). Keep the pinned `design/` commit unless intentionally updating the design system. +Mobile UX guidance is maintained in this app's `docs/mobile-auth-ux-spec.md`, and mobile motion in `tokens/mobile-motion.json`. Keep the matching inputs aligned between Android and iOS. The existing `design/` submodule supplies the original brand masters and semantic colors; mobile-specific additions do not belong in the Design repository. + +1. Fork this repo and clone recursively (`git clone --recurse-submodules `). Keep the pinned `design/` commit unless intentionally updating the design system. 2. Edit `branding/brand.json`: set your display name, unique reverse-domain `ios_bundle_id`, Apple `ios_team_id`, HTTPS deployment origin in `server_url`, and support/privacy URLs. A null server URL asks the user on launch; a fixed URL locks the build to that deployment. Paths, credentials, queries and fragments are rejected. 3. Replace `branding/app-icon.svg` with your artwork. It must have an explicit opaque top-level background rectangle (`fill="#RRGGBB"`). The generator rasterizes to an opaque 1024 px App Store icon. Point `app_icon` to this repository-local file. Change `theme_tokens` to your light/dark semantic token JSON if rebranding the theme; preserve all semantic roles. -4. Install Xcode, XcodeGen, Python 3.10+, and librsvg (`brew install xcodegen librsvg`). Run `python3 scripts/generate.py` then `xcodegen generate`. `Brand.xcconfig`, Swift constants, adaptive color assets, icons and staging schema are generated and ignored. Open the generated `SpaceWorks.xcodeproj`. Never edit or commit it. -5. Enroll your organization in the Apple Developer Program, register the bundle ID, and choose your signing team in your private build environment. Later phases enable App Attest and Push Notifications capabilities using your own identifiers. -6. Create an APNs key in your own Apple account and configure it privately on your server when A4 is implemented. iOS uses APNs directly; it does not need a Firebase project. Self-hosters publishing Android separately need their own Firebase project for Android FCM. -7. Inject signing certificates/provisioning profiles and `.p8` files from CI secrets. Never commit keys, tokens, provisioning files, or `google-services.json`. This repo's simulator CI needs no signing credentials. +4. Install and select Xcode 26.2 with its iOS 26 SDKs (the CI toolchain), XcodeGen, Python 3.10+, and librsvg (`brew install xcodegen librsvg`). Run `python3 scripts/generate.py` then `xcodegen generate`. `Brand.xcconfig`, Swift constants, adaptive color assets, icons and staging schema are generated and ignored. Open the generated `SpaceWorks.xcodeproj`. Never edit or commit it. +5. Enroll your organization in the Apple Developer Program, register the bundle ID, and choose your signing team in your private build environment. Configure App Attest and Push Notifications capabilities/provisioning for that identity. Confirm development/production environments against the backend allowlist and use real hardware for attestation verification. +6. Create an APNs key in your own Apple account and configure it privately on your server. iOS uses APNs directly; it does not need a Firebase project. Self-hosters publishing Android separately need their own Firebase project for Android FCM. Push routing also needs the published native contract and server link files; see the backend dependency issue in the release guide. +7. Inject signing certificates/provisioning profiles and `.p8` files from CI secrets. Never commit keys, tokens, provisioning files, or `google-services.json`. Simulator tests use a temporary ad-hoc `LOCALCI` Keychain identity; it is not an Apple team identity or distribution credential. 8. Run the CI checks and archive a Release build using your Apple signing configuration. Check your deployment URL, dark/light themes, VoiceOver and the largest Dynamic Type size on a device. Supply support/privacy URLs and your own App Store privacy declarations, screenshots and reviewer demo account. -9. Upload to your App Store Connect account and TestFlight first. A8 covers publication after every earlier phase is merged and tested. A0 is not store-ready and does not implement authentication, attestation, push or payments. +9. Upload an owner-signed, validated archive to your App Store Connect account and TestFlight first. Require the recorded phase checks and owner-provisioned integration tests before publication. Whole-account deletion and other missing native contracts remain release gates; an unsigned CI compile or demo journey is not a submitted build. + +Optional deployment links and artwork are configured in the same `branding/brand.json`: + +- `support_url` and `privacy_url` accept `null` or an absolute HTTPS URL. Leave them null until you have real deployment pages; their actions stay unavailable. The generator normalizes the HTTPS scheme and DNS host, and rejects credentials, malformed escapes, whitespace, control characters and invalid URL syntax before writing generated files. Paths, query strings and fragments are allowed for these page links; the separate `server_url` must remain an origin. +- Optional top-level artwork keys accept repository-local PNG paths in light/dark pairs. `logo_symbol` pairs with `logo_symbol_reversed`, `logo_stacked` with `logo_stacked_reversed`, and `logo_horizontal_one_colour_ink` with `logo_horizontal_reversed`. An omitted asset pair uses the original pinned Design masters. Supply both members of each configured pair. The generator checks each selected file's repository path, PNG header, dimensions (48–8192 pixels) and size (at most 20 MiB) before writing generated outputs; Xcode still needs to compile the selected images successfully. +- A custom display name uses native text in auth until you provide custom symbol or stacked artwork. Generated `hasCustomWordmark`, `hasCustomStacked` and `hasCustomSymbol` flags select the supplied auth artwork; when only one of those pairs is supplied, it is used across compact and regular layouts. Keep artwork static, undistorted and opaque, preserve its clear space, and inspect both color schemes. Android accepts SVG artwork for its equivalent symbols and stacked logos; use the corresponding platform format from the same licensed source. + +Run the generator tests as well as generation after changing branding. Test a fresh build with your actual name, icon, palette, artwork, support/privacy links and bundle identifier; inspect the installed app and the Release bundle rather than only the JSON configuration. The automated fixture tests prove generator behavior with temporary inputs; they do not validate your deployment pages, signing or store submission. + +Ordinary interface text uses Apple's system font with Dynamic Type. The licensed Unbounded native accent font and conversion provenance are in `SpaceWorks/Resources/Fonts/`. Obtain explicit Gilroy app-embedding permission before bundling it. Brand assets retain their upstream licenses; this repo does not grant additional embedding rights. Keep master logos static, undistorted and fully opaque with the Design clear space; motion belongs on sibling content and respects Reduce Motion. + +Register the real Google iOS OAuth audience and optional `google_reversed_client_id` callback scheme. Enable Sign in with Apple for the registered bundle/team. Organization sign-in needs its actual native callback/device-grant contract. Compiled SDK dependencies do not enable a web-only server flow automatically. Never substitute a browser cookie or access-only response for a complete native grant. + +Native capability generation supports optional `aps_environment` (`null` or `"production"`) and `associated_domains` (an explicit array of owned `applinks:`/`webcredentials:` DNS names). When push is enabled, Debug uses the development APNs environment and Release uses production. Configured capabilities require the owner's real ten-character Apple team ID and matching provisioning. Leave these inputs absent until the deployment is configured. Supply the actual associated-domain files on the owner's server; generating an entitlement does not verify them. Edit these deployment inputs instead of hand-editing generated entitlement files, which the prebuild generator recreates. See [PUSH-NOTIFICATIONS.md](PUSH-NOTIFICATIONS.md) for consent, returned registration IDs, cleanup and membership routing. -The platform system font remains in use. Obtain explicit Gilroy app-embedding permission before converting and bundling the supplied brand webfonts. Brand assets retain their upstream licenses; this repo does not grant additional embedding rights. +`SpaceWorks/Resources/PrivacyInfo.xcprivacy` declares same-app UserDefaults access (`CA92.1`) for locally stored preferences such as theme, server selection and introduction completion. It does not declare that production collects no data. Review app collection and linked SDK manifests against the actual deployment before store submission; [Apple's required-reason API guidance](https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api) explains the declaration. diff --git a/project.yml b/project.yml index e7e466e..8eebf6a 100644 --- a/project.yml +++ b/project.yml @@ -10,6 +10,7 @@ configFiles: settings: base: SWIFT_VERSION: '5.0' + SPACEWORKS_TEST_PROFILE: phone SWIFT_EMIT_LOC_STRINGS: YES CODE_SIGN_STYLE: Automatic CURRENT_PROJECT_VERSION: 1 @@ -94,4 +95,6 @@ schemes: SpaceWorksUITests: [test] test: gatherCoverageData: true + environmentVariables: + SPACEWORKS_TEST_PROFILE: $(SPACEWORKS_TEST_PROFILE) targets: [SpaceWorksTests, SpaceWorksUITests] diff --git a/scripts/audit_bundle.py b/scripts/audit_bundle.py new file mode 100644 index 0000000..3c24cc1 --- /dev/null +++ b/scripts/audit_bundle.py @@ -0,0 +1,67 @@ +"""Inspect an actual unsigned Release app; this does not validate signing or store readiness.""" +import argparse +import json +from pathlib import Path +import plistlib + + +def audit(app, brand): + app = Path(app) + failures = [] + def check(condition, message): + if not condition: + failures.append(message) + def plist(path): + try: + return plistlib.loads(path.read_bytes()) + except (OSError, ValueError, plistlib.InvalidFileException): + failures.append(f"Missing or invalid property list: {path.relative_to(app)}") + return {} + info = plist(app / 'Info.plist') + check(info.get('CFBundleIdentifier') == brand['ios_bundle_id'], 'Bundle identifier differs from branding') + check(info.get('CFBundleDisplayName') == brand['app_name'], 'Display name differs from branding') + check(set(info.get('UIDeviceFamily', [])) == {1, 2}, 'App must include both phone and tablet device families') + check(info.get('UIApplicationSceneManifest', {}).get('UIApplicationSupportsMultipleScenes') is False, + 'Single-scene credential ownership requires multiple scenes to be explicitly disabled') + check(info.get('CFBundleShortVersionString') and info.get('CFBundleVersion'), 'Missing version or build number') + check('NSAppTransportSecurity' not in info, 'Release app contains transport security overrides') + check(bool(info.get('NSCameraUsageDescription')), 'Missing camera purpose string') + schemes = {scheme for item in info.get('CFBundleURLTypes', []) for scheme in item.get('CFBundleURLSchemes', [])} + check('spaceworks' in schemes, 'Missing app callback scheme') + google = brand.get('google_reversed_client_id') + check(not google or google in schemes, 'Missing configured Google callback scheme') + required_orientations = {'UIInterfaceOrientationPortrait', 'UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight'} + for key in ['UISupportedInterfaceOrientations', 'UISupportedInterfaceOrientations~ipad']: + check(required_orientations <= set(info.get(key, [])), f'Missing supported orientations in {key}') + fonts = info.get('UIAppFonts', []) + check('Unbounded-Variable.ttf' in fonts, 'Brand font is not registered') + for font in fonts: + check(Path(font).name == font and (app / font).is_file(), f'Registered font is missing from bundle: {font}') + check((app / 'Assets.car').is_file(), 'Compiled asset catalog is missing') + check(bool(info.get('CFBundleIcons', {}).get('CFBundlePrimaryIcon', {}).get('CFBundleIconName')), 'Primary app icon is missing') + executable = info.get('CFBundleExecutable', '') + check(bool(executable) and Path(executable).name == executable and (app / executable).is_file(), 'App executable is missing') + privacy = plist(app / 'PrivacyInfo.xcprivacy') + accessed = privacy.get('NSPrivacyAccessedAPITypes', []) + check(any(item.get('NSPrivacyAccessedAPIType') == 'NSPrivacyAccessedAPICategoryUserDefaults' and 'CA92.1' in item.get('NSPrivacyAccessedAPITypeReasons', []) for item in accessed), 'App privacy manifest lacks its reviewed UserDefaults reason') + app_localizations = [app / 'en.lproj' / name for name in ['Localizable.strings', 'Localizable.stringsdict']] + check(any(path.is_file() and path.stat().st_size for path in app_localizations), 'Compiled English app localization is missing') + check((app / 'en.lproj/InfoPlist.strings').is_file(), 'Localized camera purpose resource is missing') + api_resources = [path for path in app.rglob('*.bundle') if 'SpaceWorksAPI' in path.name and (path / 'en.lproj/Localizable.strings').is_file()] + check(bool(api_resources), 'Generated API package English resource bundle is missing') + manifests = sorted(str(path.relative_to(app)) for path in app.rglob('PrivacyInfo.xcprivacy')) + result = {'kind': 'unsigned-release-resource-audit', 'bundle_id': info.get('CFBundleIdentifier'), 'version': info.get('CFBundleShortVersionString'), 'build': info.get('CFBundleVersion'), 'device_families': info.get('UIDeviceFamily'), 'privacy_manifests_present': manifests, 'api_resource_bundles': sorted(str(path.relative_to(app)) for path in api_resources), 'failures': failures} + return result + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('app', type=Path) + parser.add_argument('--brand', type=Path, default=Path('branding/brand.json')) + parser.add_argument('--output', type=Path, default=Path('validation-bundle.json')) + args = parser.parse_args() + result = audit(args.app, json.loads(args.brand.read_text(encoding='utf-8'))) + args.output.write_text(json.dumps(result, indent=2) + '\n', encoding='utf-8') + if result['failures']: + raise SystemExit('\n'.join(result['failures'])) + print('Unsigned Release bundle resource audit passed; signing and runtime integration remain separate checks.') diff --git a/scripts/brand_artwork.py b/scripts/brand_artwork.py new file mode 100644 index 0000000..19ac23c --- /dev/null +++ b/scripts/brand_artwork.py @@ -0,0 +1,46 @@ +"""Select explicit owner artwork without modifying the pinned Design masters.""" +from pathlib import Path +import struct + +DEFAULTS = { + 'logo_horizontal_one_colour_ink': ('Wordmark', 'light.png', 'lockup-horizontal-one-colour-ink.png'), + 'logo_horizontal_reversed': ('Wordmark', 'dark.png', 'lockup-horizontal-reversed.png'), + 'logo_stacked': ('BrandStacked', 'light.png', 'lockup-stacked.png'), + 'logo_stacked_reversed': ('BrandStacked', 'dark.png', 'lockup-stacked-reversed.png'), + 'logo_symbol': ('BrandSymbol', 'light.png', 'symbol.png'), + 'logo_symbol_reversed': ('BrandSymbol', 'dark.png', 'symbol-reversed.png'), +} +PAIRS = [('logo_horizontal_one_colour_ink', 'logo_horizontal_reversed'), + ('logo_stacked', 'logo_stacked_reversed'), ('logo_symbol', 'logo_symbol_reversed')] + + +def selected_artwork(root, brand): + root = Path(root).resolve() + for light, dark in PAIRS: + if (brand.get(light) is not None) != (brand.get(dark) is not None): + raise ValueError(f'Configure both {light} and {dark}, or leave both unset') + result = [] + for key, (asset, filename, master) in DEFAULTS.items(): + configured = brand.get(key) + if configured is not None and (not isinstance(configured, str) or not configured): + raise ValueError(f'{key} must name a repository-local PNG file') + path = (root / (configured if configured is not None else 'design/logo/' + master)).resolve() + if not path.is_relative_to(root) or path.suffix.lower() != '.png' or not path.is_file(): + raise ValueError(f'{key} must name an existing repository-local PNG file') + if path.stat().st_size > 20 * 1024 * 1024: + raise ValueError(f'{key} exceeds the 20 MiB artwork limit') + with path.open('rb') as stream: + header = stream.read(33) + if len(header) < 33 or header[:8] != b'\x89PNG\r\n\x1a\n' or header[12:16] != b'IHDR': + raise ValueError(f'{key} must be PNG artwork') + width, height = struct.unpack('>II', header[16:24]) + if not 48 <= width <= 8192 or not 48 <= height <= 8192: + raise ValueError(f'{key} must have dimensions from 48 through 8192 pixels') + result.append((asset, filename, path)) + return result + + +def artwork_flags(brand): + return {'hasCustomSymbol': brand.get('logo_symbol') is not None, + 'hasCustomStacked': brand.get('logo_stacked') is not None, + 'hasCustomWordmark': brand.get('logo_symbol') is not None or brand.get('logo_stacked') is not None} diff --git a/scripts/brand_links.py b/scripts/brand_links.py new file mode 100644 index 0000000..8f89266 --- /dev/null +++ b/scripts/brand_links.py @@ -0,0 +1,53 @@ +"""Validate owner-provided legal/help links and generate nullable native constants. + +These values are external navigation destinations, never API deployment origins. +Missing configuration remains unavailable; no URL or credentials are inferred. +""" +import ipaddress +import re +import unicodedata +from urllib.parse import urlsplit, urlunsplit + + +def optional_https_url(value, field): + if value is None: + return None + if not isinstance(value, str) or not value or any( + character.isspace() or unicodedata.category(character).startswith('C') or character == '\\' + for character in value + ) or any(character in value for character in '\"<>^`{}|') or re.search(r'%(?![0-9A-Fa-f]{2})', value): + raise ValueError(f'{field} must be null or an absolute HTTPS URL without whitespace or controls') + try: + parsed = urlsplit(value) + hostname = parsed.hostname + _ = parsed.port # Invalid/non-numeric/out-of-range ports must fail validation. + if (parsed.scheme.lower() != 'https' or not parsed.netloc or not hostname + or parsed.username is not None or parsed.password is not None + or '@' in parsed.netloc or parsed.netloc.endswith(':')): + raise ValueError('invalid HTTPS authority') + try: + address = ipaddress.ip_address(hostname) + normalized_host = '[' + hostname.lower() + ']' if address.version == 6 else hostname + except ValueError: + normalized_host = hostname.encode('idna').decode('ascii').lower() + if len(normalized_host.rstrip('.')) > 253 or not all( + re.fullmatch(r'[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?', label) + for label in normalized_host.rstrip('.').split('.') + ): + raise ValueError('invalid hostname') + if '[' in parsed.path or ']' in parsed.path or '#' in parsed.fragment: + raise ValueError('invalid URI component') + if parsed.netloc.startswith('['): + port_suffix = parsed.netloc[parsed.netloc.index(']') + 1:] + else: + port_suffix = ':' + parsed.netloc.rsplit(':', 1)[1] if ':' in parsed.netloc else '' + normalized = urlunsplit(('https', normalized_host + port_suffix, parsed.path, parsed.query, parsed.fragment)) + except (ValueError, UnicodeError): + # Errors identify the field without echoing owner URLs or credentials. + raise ValueError(f'{field} must be null or an absolute HTTPS URL without credentials') from None + return normalized + + + +def validated_links(brand): + return {key: optional_https_url(brand.get(key), key) for key in ('support_url', 'privacy_url')} diff --git a/scripts/device-matrix.py b/scripts/device-matrix.py index 331f4cb..779df21 100644 --- a/scripts/device-matrix.py +++ b/scripts/device-matrix.py @@ -48,7 +48,10 @@ def select(profile): if profile in ('large', 'tablet-landscape', 'large-tablet'): simctl('ui', identifier, 'content_size', 'accessibility-extra-extra-extra-large') evidence = {'profile': profile, 'name': name, 'udid': identifier, 'runtime': runtime['version'], - 'appearance': appearance, 'landscape_required': profile == 'tablet-landscape', + 'appearance': appearance, 'expected_app_appearance': appearance, + 'launch_orientation': 'landscape' if profile == 'tablet-landscape' else 'portrait', + 'reduce_motion_environment': profile == 'large-tablet', + 'landscape_required': profile == 'tablet-landscape', 'large_text': profile in ('large', 'tablet-landscape', 'large-tablet')} pathlib.Path('validation-device.json').write_text(json.dumps(evidence, indent=2) + '\n', encoding='utf-8') with open(os.environ['GITHUB_ENV'], 'a', encoding='utf-8') as output: diff --git a/scripts/generate.py b/scripts/generate.py index 3158370..5d058f5 100644 --- a/scripts/generate.py +++ b/scripts/generate.py @@ -31,6 +31,37 @@ def inside(relative): def generate(raster=True): brand = json.loads((ROOT / "branding/brand.json").read_text()) + links_spec = importlib.util.spec_from_file_location("spaceworks_brand_links", ROOT / "scripts/brand_links.py") + links_module = importlib.util.module_from_spec(links_spec) + links_spec.loader.exec_module(links_module) + # Validate destinations before writing any generated output. Only these optional + # external links are normalized; the deployment origin has its own strict policy. + brand.update(links_module.validated_links(brand)) + artwork_spec = importlib.util.spec_from_file_location("spaceworks_artwork", ROOT / "scripts/brand_artwork.py") + artwork_module = importlib.util.module_from_spec(artwork_spec) + artwork_spec.loader.exec_module(artwork_module) + selected_artwork = artwork_module.selected_artwork(ROOT, brand) + artwork_flags = artwork_module.artwork_flags(brand) + icon_source = inside(brand["app_icon"]) + theme_source = inside(brand["theme_tokens"]) + if not icon_source.is_file() or icon_source.suffix.lower() != ".svg": + raise ValueError("app_icon must name a repository-local SVG file") + try: + if ET.parse(icon_source).getroot().tag.split("}")[-1] != "svg": + raise ValueError("app_icon must contain SVG artwork") + except (ET.ParseError, OSError) as error: + raise ValueError("app_icon must contain valid SVG artwork") from error + if not theme_source.is_file(): + raise ValueError("theme_tokens must name a repository-local JSON file") + tokens = json.loads(theme_source.read_text(encoding="utf-8")) + if not isinstance(tokens, dict) or any(not isinstance(tokens.get(mode), dict) or not tokens[mode] for mode in ["light", "dark"]): + raise ValueError("theme_tokens requires nonempty light and dark semantic roles") + if set(tokens["light"]) != set(tokens["dark"]): + raise ValueError("Light and dark semantic roles must match") + for mode in ["light", "dark"]: + for role, color in tokens[mode].items(): + if not isinstance(color, str) or not re.fullmatch(r"#[0-9A-Fa-f]{6}", color): + raise ValueError(f"Invalid token {mode}.{role}") for key in ["ios_bundle_id", "ios_team_id"]: if not re.fullmatch(r"[A-Za-z0-9.-]*", brand[key]): raise ValueError(f"Invalid {key}") @@ -54,6 +85,7 @@ def generate(raster=True): write(ROOT / "SpaceWorks/Generated/Brand.swift", "// Generated. Do not edit.\nimport Foundation\nenum Brand {\n" + "\n".join(f" static let {name}: String{'?' if key in ['server_url', 'support_url', 'privacy_url'] else ''} = {swift_literal(brand[key])}" for name, key in [("appName", "app_name"), ("bundleID", "ios_bundle_id"), ("serverURL", "server_url"), ("supportURL", "support_url"), ("privacyURL", "privacy_url")]) + + "\n" + "\n".join(f" static let {name} = {str(value).lower()}" for name, value in artwork_flags.items()) + "\n static let pushConfigured = " + str(push_configured).lower() + "\n #if DEBUG\n static let pushEnvironment = \"development\"\n #else\n static let pushEnvironment = \"production\"\n #endif\n" + " static let associatedDomains: [String] = " + json.dumps(brand.get("associated_domains", [])) @@ -86,7 +118,7 @@ def generate(raster=True): (ROOT / "SpaceWorks/Generated/Simulator.entitlements").write_bytes(plistlib.dumps({ "application-identifier": simulator_identity, "keychain-access-groups": [simulator_identity], "get-task-allow": True})) - tokens = json.loads(inside(brand["theme_tokens"]).read_text()) + # Theme input was parsed and validated before generated output writes. if set(tokens["light"]) != set(tokens["dark"]): raise ValueError("Light and dark semantic roles must match") assets = ROOT / "SpaceWorks/Assets.xcassets" @@ -123,18 +155,12 @@ def generate(raster=True): account_module = importlib.util.module_from_spec(account_spec) account_spec.loader.exec_module(account_module) account_module.generate_account_fixtures(ROOT) - wordmark = assets / "Wordmark.imageset" - wordmark.mkdir(parents=True, exist_ok=True) - for name, master in [("light.png", "lockup-horizontal-one-colour-ink.png"), ("dark.png", "lockup-horizontal-reversed.png")]: - shutil.copyfile(ROOT / "design/logo" / master, wordmark / name) - write(wordmark / "Contents.json", json.dumps({"images": [{"idiom": "universal", "filename": "light.png"}, - {"idiom": "universal", "filename": "dark.png", "appearances": [{"appearance": "luminosity", "value": "dark"}]}], - "info": {"author": "xcode", "version": 1}})) - for asset, master in [("BrandStacked", "lockup-stacked"), ("BrandSymbol", "symbol")]: + for asset in ["Wordmark", "BrandStacked", "BrandSymbol"]: target = assets / f"{asset}.imageset" target.mkdir(parents=True, exist_ok=True) - for name, suffix in [("light.png", ""), ("dark.png", "-reversed")]: - shutil.copyfile(ROOT / "design/logo" / f"{master}{suffix}.png", target / name) + for selected_asset, name, master in selected_artwork: + if selected_asset == asset: + shutil.copyfile(master, target / name) write(target / "Contents.json", json.dumps({"images": [{"idiom": "universal", "filename": "light.png"}, {"idiom": "universal", "filename": "dark.png", "appearances": [{"appearance": "luminosity", "value": "dark"}]}], "info": {"author": "xcode", "version": 1}})) diff --git a/scripts/tests/test_audit_bundle.py b/scripts/tests/test_audit_bundle.py new file mode 100644 index 0000000..ca95352 --- /dev/null +++ b/scripts/tests/test_audit_bundle.py @@ -0,0 +1,70 @@ +import importlib.util +from pathlib import Path +import plistlib +import tempfile +import unittest + +module_path = Path(__file__).resolve().parents[1] / 'audit_bundle.py' +if not module_path.exists(): + module_path = Path(__file__).resolve().parent / 'audit_bundle.py' +spec = importlib.util.spec_from_file_location('audit_bundle', module_path) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +class ReleaseBundleAuditTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.app = Path(self.directory.name) / 'SpaceWorks.app' + self.app.mkdir() + self.brand = {'ios_bundle_id': 'org.spaceworks.app', 'app_name': 'Space Works'} + self.info = {'CFBundleIdentifier': 'org.spaceworks.app', 'CFBundleDisplayName': 'Space Works', 'UIDeviceFamily': [1, 2], 'CFBundleShortVersionString': '0.1.0', 'CFBundleVersion': '1', 'NSCameraUsageDescription': 'Camera use', 'CFBundleURLTypes': [{'CFBundleURLSchemes': ['spaceworks']}], 'UISupportedInterfaceOrientations': ['UIInterfaceOrientationPortrait', 'UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight'], 'UISupportedInterfaceOrientations~ipad': ['UIInterfaceOrientationPortrait', 'UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight'], 'UIAppFonts': ['Unbounded-Variable.ttf'], 'CFBundleExecutable': 'SpaceWorks', 'CFBundleIcons': {'CFBundlePrimaryIcon': {'CFBundleIconName': 'AppIcon'}}} + self.info['UIApplicationSceneManifest'] = {'UIApplicationSupportsMultipleScenes': False} + self.write_info() + (self.app / 'PrivacyInfo.xcprivacy').write_bytes(plistlib.dumps({'NSPrivacyAccessedAPITypes': [{'NSPrivacyAccessedAPIType': 'NSPrivacyAccessedAPICategoryUserDefaults', 'NSPrivacyAccessedAPITypeReasons': ['CA92.1']}]})) + for name in ['Unbounded-Variable.ttf', 'Assets.car', 'SpaceWorks', 'en.lproj/InfoPlist.strings', 'en.lproj/Localizable.strings', 'SpaceWorksAPI_SpaceWorksAPI.bundle/en.lproj/Localizable.strings']: + path = self.app / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'fixture') + + def write_info(self): + (self.app / 'Info.plist').write_bytes(plistlib.dumps(self.info)) + + def test_accepts_packaged_resources_and_records_unsigned_scope(self): + result = module.audit(self.app, self.brand) + self.assertEqual(result['failures'], []) + self.assertEqual(result['kind'], 'unsigned-release-resource-audit') + self.assertEqual(result['privacy_manifests_present'], ['PrivacyInfo.xcprivacy']) + + def test_catches_source_resources_omitted_from_app(self): + (self.app / 'PrivacyInfo.xcprivacy').unlink() + (self.app / 'Unbounded-Variable.ttf').unlink() + (self.app / 'SpaceWorksAPI_SpaceWorksAPI.bundle/en.lproj/Localizable.strings').unlink() + failures = module.audit(self.app, self.brand)['failures'] + self.assertTrue(any('PrivacyInfo.xcprivacy' in failure for failure in failures)) + self.assertTrue(any('Registered font' in failure for failure in failures)) + self.assertTrue(any('API package' in failure for failure in failures)) + + def test_refuses_release_transport_overrides_wrong_brand_and_phone_only(self): + self.info.update({'NSAppTransportSecurity': {'NSAllowsLocalNetworking': True}, 'CFBundleIdentifier': 'other.app', 'UIDeviceFamily': [1]}) + self.write_info() + failures = module.audit(self.app, self.brand)['failures'] + self.assertTrue(any('transport security' in failure for failure in failures)) + self.assertTrue(any('identifier' in failure for failure in failures)) + self.assertTrue(any('device families' in failure for failure in failures)) + + def test_requires_configured_callback_and_compiled_localization(self): + self.brand['google_reversed_client_id'] = 'com.googleusercontent.apps.example' + (self.app / 'en.lproj/Localizable.strings').unlink() + failures = module.audit(self.app, self.brand)['failures'] + self.assertTrue(any('Google callback' in failure for failure in failures)) + self.assertTrue(any('app localization' in failure for failure in failures)) + + def test_requires_explicit_single_scene_credential_contract(self): + for manifest in [{}, {'UIApplicationSupportsMultipleScenes': True}]: + self.info['UIApplicationSceneManifest'] = manifest + self.write_info() + self.assertTrue(any('Single-scene' in failure for failure in module.audit(self.app, self.brand)['failures'])) + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/tests/test_brand_artwork.py b/scripts/tests/test_brand_artwork.py new file mode 100644 index 0000000..f9a58f4 --- /dev/null +++ b/scripts/tests/test_brand_artwork.py @@ -0,0 +1,46 @@ +from pathlib import Path +import importlib.util +import shutil +import tempfile +import unittest + +HERE = Path(__file__).resolve().parent +SPEC = importlib.util.spec_from_file_location('brand_artwork', HERE.parent / 'brand_artwork.py') +ART = importlib.util.module_from_spec(SPEC); SPEC.loader.exec_module(ART) +ROOT = HERE.parents[1] + +class ArtworkTests(unittest.TestCase): + def make_root(self, root): + (root / 'design/logo').mkdir(parents=True) + for _, _, master in ART.DEFAULTS.values(): + shutil.copyfile(ROOT / 'design/logo' / master, root / 'design/logo' / master) + def test_unconfigured_artwork_preserves_all_existing_masters(self): + selected = ART.selected_artwork(ROOT, {}) + self.assertEqual(len(selected), 6) + self.assertTrue(all('design' in path.parts for _, _, path in selected)) + self.assertFalse(any(ART.artwork_flags({}).values())) + def test_custom_light_dark_artwork_is_selected_with_native_renderer_flags(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary); self.make_root(root) + (root / 'branding').mkdir() + brand = {} + for key, (_, _, master) in ART.DEFAULTS.items(): + brand[key] = 'branding/' + key + '.png' + shutil.copyfile(root / 'design/logo' / master, root / brand[key]) + selected = ART.selected_artwork(root, brand) + self.assertTrue(all('branding' in path.parts for _, _, path in selected)) + self.assertTrue(all(ART.artwork_flags(brand).values())) + self.assertEqual({(asset, name) for asset, name, _ in selected}, + {('Wordmark', 'light.png'), ('Wordmark', 'dark.png'), ('BrandStacked', 'light.png'), ('BrandStacked', 'dark.png'), ('BrandSymbol', 'light.png'), ('BrandSymbol', 'dark.png')}) + def test_partial_pair_missing_file_escape_and_non_png_fail_before_use(self): + for brand in ({'logo_symbol':'design/logo/symbol.png'}, + {'logo_symbol':'../outside.png','logo_symbol_reversed':'design/logo/symbol.png'}, + {'logo_stacked':'missing.png','logo_stacked_reversed':'missing.png'}, + {'logo_stacked':True,'logo_stacked_reversed':False}, + {'logo_symbol':'design/logo/symbol.svg','logo_symbol_reversed':'design/logo/symbol-reversed.png'}): + with self.subTest(brand=brand), self.assertRaises(ValueError): ART.selected_artwork(ROOT, brand) + def test_single_custom_pair_does_not_claim_other_asset_was_customized(self): + brand={'logo_stacked':'design/logo/lockup-stacked.png','logo_stacked_reversed':'design/logo/lockup-stacked-reversed.png'} + self.assertEqual(ART.artwork_flags(brand), {'hasCustomSymbol':False,'hasCustomStacked':True,'hasCustomWordmark':True}) + +if __name__ == '__main__': unittest.main() diff --git a/scripts/tests/test_brand_links.py b/scripts/tests/test_brand_links.py new file mode 100644 index 0000000..6d2b9fa --- /dev/null +++ b/scripts/tests/test_brand_links.py @@ -0,0 +1,60 @@ +import importlib.util +from pathlib import Path +import unittest +SPEC = importlib.util.spec_from_file_location('brand_links', Path(__file__).resolve().parents[1] / 'brand_links.py') +LINKS = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(LINKS) +class BrandLinksTest(unittest.TestCase): + def test_unconfigured_links_remain_nullable_without_invented_destinations(self): + self.assertEqual(LINKS.validated_links({}), {'support_url': None, 'privacy_url': None}) + def test_legal_paths_queries_fragments_and_ports_are_preserved(self): + for value in ('https://help.example.org/support/contact?topic=account&lang=en#email', + 'https://privacy.example.org:8443/legal/a%20b?return=$account#retention', + 'https://[2001:db8::1]:443/privacy', 'https://localhost/privacy'): + with self.subTest(value=value): + self.assertEqual(LINKS.optional_https_url(value, 'support_url'), value) + + def test_scheme_and_idna_host_are_normalized_for_native_external_links(self): + self.assertEqual(LINKS.optional_https_url('HTTPS://B\u00dcCHER.example:0443/p%20a?q=$owner#legal', 'support_url'), + 'https://xn--bcher-kva.example:0443/p%20a?q=$owner#legal') + self.assertEqual(LINKS.optional_https_url('HTTPS://[2001:DB8::1]:443/policy', 'privacy_url'), + 'https://[2001:db8::1]:443/policy') + self.assertEqual(LINKS.optional_https_url('HTTPS://EXAMPLE.ORG/legal', 'support_url'), + 'https://example.org/legal') + + def test_malformed_escapes_and_raw_java_uri_illegal_characters_are_rejected(self): + for suffix in ('%ZZ', '%2', '%', 'a{b}', 'a}b', 'a"b', 'ab', 'a|b', + 'a^b', 'a`b', 'a[b]', '?q={bad}', '#bad#fragment'): + with self.subTest(suffix=suffix), self.assertRaises(ValueError): + LINKS.optional_https_url('https://example.org/' + suffix, 'privacy_url') + self.assertEqual(LINKS.optional_https_url('https://example.org/a%7Bb%7D?q=%22ok%22#part%25', 'privacy_url'), + 'https://example.org/a%7Bb%7D?q=%22ok%22#part%25') + + def test_insecure_relative_missing_host_credentials_and_malformed_authority_are_rejected(self): + for value in ('', 'http://example.org', '//example.org/a', '/legal', 'https:example.org', + 'https:///legal', 'https://', 'javascript:alert(1)', + 'https://user:secret@example.org/legal', 'https://@example.org', + 'https://example.org@attacker.example', 'https://example.org:bad', + 'https://example.org:65536', 'https://example.org:', + 'https://bad%2f.example/legal', 'https://.example.org', 'https://[invalid]/'): + with self.subTest(value=value), self.assertRaises(ValueError): + LINKS.optional_https_url(value, 'privacy_url') + + def test_controls_whitespace_backslashes_and_nonstring_values_are_rejected(self): + for value in (' https://example.org', 'https://example.org ', 'https://example.org/a b', + 'https://example.org\n', 'https://example.org/\tpolicy', + 'https://example.org/\x00policy', 'https://example.org/\x7f', + 'https://example.org/\u200bpolicy', 'https://example.org/\u00a0policy', + 'https://example.org\\@attacker.example', True, False, 42, [], {}): + with self.subTest(value=value), self.assertRaises(ValueError): + LINKS.optional_https_url(value, 'support_url') + + def test_error_does_not_echo_private_value(self): + with self.assertRaises(ValueError) as result: + LINKS.optional_https_url('https://user:super-secret@example.org/private', 'support_url') + self.assertIn('support_url', str(result.exception)) + self.assertNotIn('super-secret', str(result.exception)) + self.assertNotIn('example.org', str(result.exception)) + + +if __name__ == "__main__": unittest.main() diff --git a/scripts/tests/test_generation.py b/scripts/tests/test_generation.py index 75bd03e..d848fb7 100644 --- a/scripts/tests/test_generation.py +++ b/scripts/tests/test_generation.py @@ -4,6 +4,9 @@ import pathlib import plistlib import struct +import shutil +import tempfile +from unittest import mock import unittest ROOT = pathlib.Path(__file__).resolve().parents[2] @@ -40,7 +43,9 @@ def test_brand_config_drives_project_display_name_bundle_and_icon(self): self.assertIn("$(BRAND_DISPLAY_NAME)", project) self.assertIn("Brand.xcconfig", project) self.assertIn("AppIcon", project) - self.assertEqual((ROOT / brand["app_icon"]).read_bytes(), (ROOT / "design/logo/app-icon.svg").read_bytes()) + source = generator.inside(brand["app_icon"]) + self.assertTrue(source.is_file(), "Configured repository-local icon source must exist") + self.assertTrue((ROOT / "SpaceWorks/Assets.xcassets/AppIcon.appiconset/Contents.json").is_file()) icon = ROOT / "SpaceWorks/Assets.xcassets/AppIcon.appiconset/AppIcon.png" if icon.exists(): data = icon.read_bytes() @@ -48,15 +53,78 @@ def test_brand_config_drives_project_display_name_bundle_and_icon(self): self.assertEqual(data[25], 2, "App Store icon must be opaque RGB") def test_every_semantic_role_has_both_appearances(self): - tokens = json.loads((ROOT / "design/tokens/tokens.json").read_text()) + brand = json.loads((ROOT / "branding/brand.json").read_text()) + tokens = json.loads(generator.inside(brand["theme_tokens"]).read_text()) for role in tokens["light"]: asset = json.loads((ROOT / f"SpaceWorks/Assets.xcassets/{role}.colorset/Contents.json").read_text()) for index, mode in enumerate(["light", "dark"]): rgb = asset["colors"][index]["color"]["components"] actual = "#" + "".join(rgb[k][2:] for k in ["red", "green", "blue"]) self.assertEqual(actual.upper(), tokens[mode][role].upper()) - self.assertEqual(tokens["light"]["bg"], "#FFF4E8") - self.assertEqual(tokens["dark"]["bg"], "#142C2A") + baseline = json.loads((ROOT / "design/tokens/tokens.json").read_text()) + for mode in ("light", "dark"): + self.assertTrue(set(baseline[mode]).issubset(tokens[mode]), "Custom themes must retain required semantic roles") + + def test_custom_configuration_generates_custom_artwork_palette_and_renderer_flags(self): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary).resolve() + for folder in ["scripts", "branding", "tokens", "api", "Fixtures"]: + shutil.copytree(ROOT / folder, root / folder, ignore=shutil.ignore_patterns("__pycache__")) + (root / "design/logo").mkdir(parents=True) + for name in ["lockup-horizontal-one-colour-ink.png", "lockup-horizontal-reversed.png", "lockup-stacked.png", "lockup-stacked-reversed.png", "symbol.png", "symbol-reversed.png"]: + shutil.copyfile(ROOT / "design/logo" / name, root / "design/logo" / name) + brand = json.loads((root / "branding/brand.json").read_text()) + brand.update(app_name="Workshop", ios_bundle_id="org.example.workshop", app_icon="branding/custom-icon.svg", theme_tokens="branding/custom-tokens.json", privacy_url="HTTPS://B\u00dcCHER.example/privacy?lang=en#retention") + (root / brand["app_icon"]).write_text((ROOT / "branding/app-icon.svg").read_text().replace("#FFF4E8", "#FAF2E8")) + # Distinct bytes prove the selected custom sources actually reach the assets. + for key, master in [("logo_symbol", "symbol-reversed.png"), ("logo_symbol_reversed", "symbol.png"), ("logo_stacked", "lockup-stacked-reversed.png"), ("logo_stacked_reversed", "lockup-stacked.png")]: + brand[key] = "branding/" + key + ".png" + shutil.copyfile(root / "design/logo" / master, root / brand[key]) + tokens = json.loads((ROOT / "design/tokens/tokens.json").read_text()) + tokens["light"]["bg"] = "#FAF2E8" + (root / brand["theme_tokens"]).write_text(json.dumps(tokens)) + (root / "branding/brand.json").write_text(json.dumps(brand)) + with mock.patch.object(generator, "ROOT", root), mock.patch.object(generator.subprocess, "run") as raster: + generator.generate(raster=True) + swift = (root / "SpaceWorks/Generated/Brand.swift").read_text() + self.assertIn('appName: String = "Workshop"', swift) + self.assertIn('privacyURL: String? = "https://xn--bcher-kva.example/privacy?lang=en#retention"', swift) + self.assertIn('hasCustomWordmark = true', swift) + self.assertIn('hasCustomStacked = true', swift) + self.assertIn('hasCustomSymbol = true', swift) + self.assertEqual((root / "SpaceWorks/Assets.xcassets/BrandSymbol.imageset/light.png").read_bytes(), (root / brand["logo_symbol"]).read_bytes()) + self.assertEqual((root / "SpaceWorks/Assets.xcassets/BrandStacked.imageset/dark.png").read_bytes(), (root / brand["logo_stacked_reversed"]).read_bytes()) + color = json.loads((root / "SpaceWorks/Assets.xcassets/bg.colorset/Contents.json").read_text())["colors"][0]["color"]["components"] + self.assertEqual([color[key] for key in ["red", "green", "blue"]], ["0xFA", "0xF2", "0xE8"]) + self.assertEqual(raster.call_args.args[0][-1], str(root / brand["app_icon"])) + self.assertIn("--background-color", raster.call_args.args[0]) + self.assertEqual(raster.call_args.args[0][2], "#FAF2E8") + + def test_invalid_external_link_or_artwork_cannot_partially_rewrite_outputs(self): + for changes in [{"privacy_url": "https://user:private@example.org/legal"}, {"logo_symbol": "missing.png"}, + {"app_icon": "missing.svg"}, {"app_icon": "../outside.svg"}, {"theme_tokens": "missing.json"}]: + with self.subTest(changes=changes), tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary).resolve() + (root / "branding").mkdir(); (root / "scripts").mkdir() + for name in ["brand_links.py", "brand_artwork.py"]: + shutil.copyfile(ROOT / "scripts" / name, root / "scripts" / name) + brand = json.loads((ROOT / "branding/brand.json").read_text()) + shutil.copytree(ROOT / "design/logo", root / "design/logo") + for relative in [brand["theme_tokens"], brand["app_icon"], *[value for key, value in brand.items() if key.startswith("logo_") and value is not None]]: + destination = root / relative; destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(ROOT / relative, destination) + brand.update(changes) + (root / "branding/brand.json").write_text(json.dumps(brand)) + output = root / "Brand.xcconfig"; output.write_text("keep existing build") + with mock.patch.object(generator, "ROOT", root), self.assertRaises(ValueError): generator.generate(raster=False) + self.assertEqual(output.read_text(), "keep existing build") + self.assertFalse((root / "SpaceWorks/Generated").exists()) + + def test_upstream_master_palette_remains_the_original_brand_baseline(self): + # This immutable upstream check is deliberately separate from configured output. + baseline = json.loads((ROOT / "design/tokens/tokens.json").read_text()) + self.assertEqual(baseline["light"]["bg"], "#FFF4E8") + self.assertEqual(baseline["dark"]["bg"], "#142C2A") def test_theme_and_brand_generation_are_deterministic(self): paths = [ROOT / "SpaceWorks/Generated/Theme.swift", ROOT / "SpaceWorks/Generated/Brand.swift", ROOT / "Brand.xcconfig"] @@ -69,7 +137,8 @@ def luminance(code): rgb = [int(code[i:i+2], 16) / 255 for i in [1, 3, 5]] linear = [v / 12.92 if v <= .04045 else ((v + .055) / 1.055) ** 2.4 for v in rgb] return sum(a * b for a, b in zip(linear, [.2126, .7152, .0722])) - tokens = json.loads((ROOT / "design/tokens/tokens.json").read_text()) + brand = json.loads((ROOT / "branding/brand.json").read_text()) + tokens = json.loads(generator.inside(brand["theme_tokens"]).read_text()) for mode in ["light", "dark"]: for ink, bg in [("ink", "bg"), ("muted", "bg"), ("ink", "surface"), ("muted", "surface"), ("accent-ink", "surface"), ("on-accent", "accent")]: values = sorted([luminance(tokens[mode][ink]), luminance(tokens[mode][bg])]) diff --git a/scripts/tests/test_mobile_capabilities.py b/scripts/tests/test_mobile_capabilities.py index 2b21b6c..4687eb3 100644 --- a/scripts/tests/test_mobile_capabilities.py +++ b/scripts/tests/test_mobile_capabilities.py @@ -32,7 +32,11 @@ def test_prebuild_preserves_explicit_capabilities_and_removes_disabled_ones(self generation_spec.loader.exec_module(generator) with tempfile.TemporaryDirectory() as directory: root = pathlib.Path(directory).resolve() - for relative in ["branding/brand.json", "api/openapi.json", "api/provenance.json", "tokens/mobile-motion.json", "design/tokens/tokens.json"]: + inputs = json.loads((ROOT / "branding/brand.json").read_text(encoding="utf-8")) + files = ["branding/brand.json", "api/openapi.json", "api/provenance.json", "tokens/mobile-motion.json", inputs["theme_tokens"], inputs["app_icon"]] + files += [value for key, value in inputs.items() if key.startswith("logo_") and value is not None] + shutil.copytree(ROOT / "design/logo", root / "design/logo") + for relative in dict.fromkeys(files): target = root / relative target.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(ROOT / relative, target) diff --git a/scripts/tests/test_motion.py b/scripts/tests/test_motion.py index 7afb217..16828f1 100644 --- a/scripts/tests/test_motion.py +++ b/scripts/tests/test_motion.py @@ -27,11 +27,13 @@ def test_motion_rejects_logo_animation_and_reduced_motion_delays(self): with self.assertRaises(ValueError): motion.render_motion(document) - def test_native_logo_assets_are_exact_unmodified_masters_in_both_appearances(self): - for asset, master in [("BrandStacked", "lockup-stacked"), ("BrandSymbol", "symbol")]: - for filename, suffix in [("light.png", ""), ("dark.png", "-reversed")]: - self.assertEqual((ROOT / f"SpaceWorks/Assets.xcassets/{asset}.imageset/{filename}").read_bytes(), - (ROOT / f"design/logo/{master}{suffix}.png").read_bytes()) + def test_native_logo_assets_preserve_configured_source_bytes_in_both_appearances(self): + artwork_spec = importlib.util.spec_from_file_location("artwork", ROOT / "scripts/brand_artwork.py") + artwork = importlib.util.module_from_spec(artwork_spec) + artwork_spec.loader.exec_module(artwork) + brand = json.loads((ROOT / "branding/brand.json").read_text()) + for asset, filename, source in artwork.selected_artwork(ROOT, brand): + self.assertEqual((ROOT / f"SpaceWorks/Assets.xcassets/{asset}.imageset/{filename}").read_bytes(), source.read_bytes()) def test_native_accent_font_has_recorded_license_and_provenance(self): font = ROOT / "SpaceWorks/Resources/Fonts/Unbounded-Variable.ttf" From 8ff47bc1d660d3ca2d69ae0c33e91e4d37e4e2ac Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:16:29 +0530 Subject: [PATCH 42/98] fix(a7): render remote staff row titles verbatim Use Text for the staff menu title presentation, keeping literal app titles localized and server-provided names explicitly verbatim. Record the initial native compiler checkpoint without claiming runtime acceptance. Co-Authored-By: Codex --- SpaceWorks/StaffViews.swift | 14 +++++++------- docs/CHANGELOG.md | 2 ++ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/SpaceWorks/StaffViews.swift b/SpaceWorks/StaffViews.swift index 656df48..66ce339 100644 --- a/SpaceWorks/StaffViews.swift +++ b/SpaceWorks/StaffViews.swift @@ -11,16 +11,16 @@ struct StaffView: View { if state.isDemo { Label("Demo staff tools · sample data", systemImage: "info.circle").foregroundStyle(Color.swMuted) } SWCard { if ["accept_request", "reject_request", "issue_request", "return_request"].contains(where: state.can) { - NavigationLink { StaffRequestsView(state: state) } label: { StaffMenuRow(title: "Requests and loans", detail: "Review requests and guide equipment handovers", symbol: "shippingbox") }.accessibilityIdentifier("staff-requests") + NavigationLink { StaffRequestsView(state: state) } label: { StaffMenuRow(title: Text("Requests and loans"), detail: "Review requests and guide equipment handovers", symbol: "shippingbox") }.accessibilityIdentifier("staff-requests") } if state.can("issue_direct_loan") || state.canReturn { - NavigationLink { StaffDirectLoansView(state: state) } label: { StaffMenuRow(title: "Direct handout", detail: "Choose a borrower, equipment, and evidence", symbol: "person.crop.rectangle") }.accessibilityIdentifier("staff-direct") + NavigationLink { StaffDirectLoansView(state: state) } label: { StaffMenuRow(title: Text("Direct handout"), detail: "Choose a borrower, equipment, and evidence", symbol: "person.crop.rectangle") }.accessibilityIdentifier("staff-direct") } if state.can("manage_bookings") { - NavigationLink { StaffSpacesView(state: state) } label: { StaffMenuRow(title: "Booking approvals", detail: "Review space reservations", symbol: "calendar") }.accessibilityIdentifier("staff-bookings") + NavigationLink { StaffSpacesView(state: state) } label: { StaffMenuRow(title: Text("Booking approvals"), detail: "Review space reservations", symbol: "calendar") }.accessibilityIdentifier("staff-bookings") } if state.can("manage_events") { - NavigationLink { StaffEventsView(state: state) } label: { StaffMenuRow(title: "Event check-in", detail: "Scan, review the person, then confirm attendance", symbol: "qrcode.viewfinder") }.accessibilityIdentifier("staff-events") + NavigationLink { StaffEventsView(state: state) } label: { StaffMenuRow(title: Text("Event check-in"), detail: "Scan, review the person, then confirm attendance", symbol: "qrcode.viewfinder") }.accessibilityIdentifier("staff-events") } } Text("Only the tools allowed by your selected makerspace appear here.").font(.subheadline).foregroundStyle(Color.swMuted) @@ -30,11 +30,11 @@ struct StaffView: View { } private struct StaffMenuRow: View { - let title: LocalizedStringKey; let detail: LocalizedStringKey; let symbol: String + let title: Text; let detail: LocalizedStringKey; let symbol: String var body: some View { HStack(alignment: .top, spacing: 16) { Image(systemName: symbol).font(.title2).accessibilityHidden(true) - VStack(alignment: .leading, spacing: 8) { Text(title).font(.headline); Text(detail).font(.subheadline).foregroundStyle(Color.swMuted) } + VStack(alignment: .leading, spacing: 8) { title.font(.headline); Text(detail).font(.subheadline).foregroundStyle(Color.swMuted) } Spacer(minLength: 0); Image(systemName: "chevron.right").accessibilityHidden(true) }.frame(minHeight: 64).padding(.vertical, 12).foregroundStyle(Color.swInk) } @@ -191,7 +191,7 @@ private struct StaffSpacesView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 24) { StaffFeedback(load: state.spacesLoad) { await state.loadSpaces() } ForEach(state.spaces, id: \.id) { space in - NavigationLink { StaffBookingsView(state: state, spaceID: space.id, name: space.name) } label: { SWCard { StaffMenuRow(title: space.name, detail: "Review reservations", symbol: "calendar") } }.buttonStyle(.plain) + NavigationLink { StaffBookingsView(state: state, spaceID: space.id, name: space.name) } label: { SWCard { StaffMenuRow(title: Text(verbatim: space.name), detail: "Review reservations", symbol: "calendar") } }.buttonStyle(.plain) } if state.spacesLoad == .ready && state.spaces.isEmpty { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } if state.nextSpacePage != nil { Button("Load more spaces") { Task { await state.loadSpaces(more: true) } }.frame(minHeight: 48) } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3f551e3..c8ddad5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## A7 Account and localization — implementation checkpoint (2026-10-17) +The initial [`d05dcfc` native checkpoint](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/39017485631) stopped during compilation before tests: the localized staff menu wrapper accepted only a localization key, while a booking row supplied its server-provided space name. The wrapper now accepts a Text presentation: app-owned literal titles stay localized and the remote name is explicitly verbatim. The complete native gate remains pending; this compiler checkpoint is not acceptance. + Provide global Account and security before joining a makerspace: authenticator status, phone linking, linked providers, password change and native Devices. Separately gate member Sessions, membership cancellation/leave and data exports with the actual selected membership and bootstrap capabilities. From 96c25e69d862d3d927239bdaed46420f9a1a27a2 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:45:31 +0530 Subject: [PATCH 43/98] fix(a8): consume reduce motion policy in introduction Read the system preference without overriding SwiftUI's read-only environment. Keep the UI-test motion flag local to the Debug introduction and preserve Release OS behavior. Co-Authored-By: Codex --- SpaceWorks/IntroductionView.swift | 12 +++++++++++- SpaceWorks/RootView.swift | 7 ------- docs/CHANGELOG.md | 2 ++ 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/SpaceWorks/IntroductionView.swift b/SpaceWorks/IntroductionView.swift index 11fae9e..b8e6f49 100644 --- a/SpaceWorks/IntroductionView.swift +++ b/SpaceWorks/IntroductionView.swift @@ -3,7 +3,7 @@ import SwiftUI struct IntroductionView: View { let finish: () -> Void @State private var page = 0 - @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.accessibilityReduceMotion) private var systemReduceMotion @Environment(\.dynamicTypeSize) private var typeSize private let headings = [String(localized: "Make room."), String(localized: "Borrow with confidence."), String(localized: "One place for your making.")] private let details = [String(localized: "Find tools, book a space, and see what's next at your makerspace."), @@ -12,6 +12,16 @@ struct IntroductionView: View { private let symbols = [["wrench.and.screwdriver", "shippingbox", "hammer"], ["list.clipboard", "calendar", "checkmark.seal"], ["calendar", "ticket", "tray"]] + private var reduceMotion: Bool { + #if DEBUG + // Test-only motion preference is consumed locally; Apple's environment is read-only. + return systemReduceMotion || + (ProcessInfo.processInfo.arguments.contains("--ui-testing") && + ProcessInfo.processInfo.environment["SPACEWORKS_TEST_REDUCE_MOTION"] == "1") + #else + return systemReduceMotion + #endif + } var body: some View { GeometryReader { geometry in ScrollView { diff --git a/SpaceWorks/RootView.swift b/SpaceWorks/RootView.swift index 21d54d8..49766ec 100644 --- a/SpaceWorks/RootView.swift +++ b/SpaceWorks/RootView.swift @@ -3,7 +3,6 @@ import SpaceWorksAPI struct RootView: View { @State private var state = AppState() - @Environment(\.accessibilityReduceMotion) private var systemReduceMotion @State private var linkRouter = MobileLinkRouter() @State private var pendingURL: URL? @AppStorage("introduction-complete") private var introComplete = false @@ -26,12 +25,6 @@ struct RootView: View { .tint(Color.swAccentInk) .foregroundStyle(Color.swInk) .preferredColorScheme(state.darkMode ? .dark : .light) - #if DEBUG - // UI automation exercises the app's reduced-motion environment; Release uses the OS. - .environment(\.accessibilityReduceMotion, systemReduceMotion || - (ProcessInfo.processInfo.arguments.contains("--ui-testing") && - ProcessInfo.processInfo.environment["SPACEWORKS_TEST_REDUCE_MOTION"] == "1")) - #endif .safeAreaInset(edge: .top, spacing: 0) { HStack { Spacer() diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 798cd3e..65707ef 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## A8 Usage guide and release handoff — implementation checkpoint (2026-10-17) +The first merged A8 [`7455146` matrix](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/39018807936) stopped during compilation before tests because SwiftUI exposes Reduce Motion as a read-only environment value. The introduction now reads the OS value and combines it locally with the Debug-only UI-test launch flag; Release continues to use the OS setting. The global environment is unchanged, including introduction replay. This compiler checkpoint provides no native test or screenshot acceptance; the corrected exact-head matrix remains required. + This isolated A8 candidate started from A7 `d05dcfc477dae19bd2acd78201f783c9d27cfd61` and now includes the `8ff47bc` staff-title compiler correction through a normal merge. A7 native acceptance remains pending [follow-up workflow 39018648885](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/39018648885); the initial 39017485631 checkpoint stopped before tests, as recorded below. This preparation does not claim native acceptance or bypass that gate. The A8 target is 294 native cases and 36 UI journeys, pending actual compiler discovery and the full matrix. Local checks passed 40 Python cases and the 779-key English catalog check. Add a replayable App guide covering navigation, requests, renewal, bookings, event tickets, From e94a5c8a2f9d89e9ac84a985224c1f1fcfc113d1 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:43:18 +0530 Subject: [PATCH 44/98] test(a7): complete refresh fixtures and bound retry synchronization Co-Authored-By: Codex --- SpaceWorksTests/AccountOperationTests.swift | 52 ++++++++++++++------- SpaceWorksTests/AuthTests.swift | 2 +- SpaceWorksTests/DeviceRefreshFixture.swift | 9 ++++ SpaceWorksTests/EmailOwnerTests.swift | 2 +- docs/CHANGELOG.md | 6 +++ 5 files changed, 52 insertions(+), 19 deletions(-) create mode 100644 SpaceWorksTests/DeviceRefreshFixture.swift diff --git a/SpaceWorksTests/AccountOperationTests.swift b/SpaceWorksTests/AccountOperationTests.swift index dddbf04..ef28a4e 100644 --- a/SpaceWorksTests/AccountOperationTests.swift +++ b/SpaceWorksTests/AccountOperationTests.swift @@ -73,7 +73,7 @@ final class AccountOperationTests: XCTestCase { let ended = AccountCallCounter() var repo = try await repository(makerspaceID: nil) { request in if request.url?.path == "/api/v1/auth/change-password" { return self.json(#"{"detail":"Password updated."}"#) } - if request.url?.path == "/api/v1/auth/device/refresh" { return self.json(#"{"access":"rotated-access","refresh":"rotated-refresh"}"#) } + if request.url?.path == "/api/v1/auth/device/refresh" { return self.json(DeviceRefreshFixture.rotated) } XCTAssertEqual(request.url?.path, "/api/v1/auth/me") return self.json(#"{"detail":"Session revoked."}"#, status: 401) } @@ -83,6 +83,10 @@ final class AccountOperationTests: XCTestCase { XCTAssertEqual(ended.value, 1) } func testAll21GeneratedAccountOperationsUseExactBodiesAndPublicArchiveHasNoAccountCredentials() async throws { + let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 + let refreshed = try decoder.decode(Components.Schemas.DeviceRefreshResponse.self, from: Data(DeviceRefreshFixture.rotated.utf8)) + XCTAssertEqual(refreshed.access, "rotated-access"); XCTAssertEqual(refreshed.refresh, "rotated-refresh") + XCTAssertEqual(refreshed.device_grant.app_id, "org.spaceworks.app") var calls = Set(); var grantsIssued = 0 let zip = try archiveBytes() let repo = try await repository { request in @@ -187,9 +191,11 @@ final class AccountOperationTests: XCTestCase { XCTAssertEqual(count.value, 0); XCTAssertEqual(ended.value, 0) } func testLateManagement401NeverProbesOrSignsOutReplacementAccount() async throws { - let finalResponse = AccountResponseGate(), calls = AccountCallCounter(), ended = AccountCallCounter(), probes = AccountCallCounter() + let entryOrCompletion = expectation(description: "Retried response arrived or operation finished") + let finalResponse = AccountResponseGate(entryOrCompletion: entryOrCompletion) + let calls = AccountCallCounter(), ended = AccountCallCounter(), probes = AccountCallCounter() var repo = try await repository(makerspaceID: nil) { request in - if request.url?.path == "/api/v1/auth/device/refresh" { return self.json(#"{"access":"rotated-access","refresh":"rotated-refresh"}"#) } + if request.url?.path == "/api/v1/auth/device/refresh" { return self.json(DeviceRefreshFixture.rotated) } if request.url?.path == "/api/v1/auth/me" { probes.increment(); return self.json(self.me) } XCTAssertEqual(request.url?.path, "/api/v1/auth/mfa/recovery") calls.increment() @@ -198,8 +204,19 @@ final class AccountOperationTests: XCTestCase { } repo.onUnauthorized = { _ in ended.increment() } let owner = repo - let old = Task { try await owner.regenerateRecovery(password: "old-password") } - await finalResponse.entered() + let old = Task { + defer { finalResponse.operationCompleted() } + return try await owner.regenerateRecovery(password: "old-password") + } + defer { finalResponse.release(); old.cancel() } + await fulfillment(of: [entryOrCompletion], timeout: 5) + guard finalResponse.didEnter else { + finalResponse.release() + // An early decode/transport failure must surface instead of waiting for an unreachable retry. + _ = try await old.value + XCTFail("Management operation finished without its held retry") + return + } try await repo.service.refresh.install(.init(access: "replacement-access", refresh: "replacement-refresh")) finalResponse.release() do { _ = try await old.value; XCTFail("Old management result accepted") } @@ -261,7 +278,7 @@ final class AccountOperationTests: XCTestCase { @MainActor func testWrongPassword401RefreshesThenProbesIdentityWithoutDestroyingAccountState() async throws { let ended = AccountCallCounter(); let refreshes = AccountCallCounter(); let checks = AccountCallCounter() var repo = try await repository { request in - if request.url?.path == "/api/v1/auth/device/refresh" { refreshes.increment(); return self.json(#"{"access":"rotated-access","refresh":"rotated-refresh"}"#) } + if request.url?.path == "/api/v1/auth/device/refresh" { refreshes.increment(); return self.json(DeviceRefreshFixture.rotated) } if request.url?.path == "/api/v1/auth/me" { checks.increment(); return self.json(self.me) } if request.url?.path == "/api/v1/auth/mfa" { return self.json(DebugAccountFixtures.mfa) } XCTAssertEqual(request.url?.path, "/api/v1/auth/mfa/recovery") @@ -277,7 +294,7 @@ final class AccountOperationTests: XCTestCase { let ended = AccountCallCounter(), count = AccountCallCounter() var repo = try await repository(makerspaceID: nil) { request in count.increment() - if request.url?.path == "/api/v1/auth/device/refresh" { return self.json(#"{"access":"rotated-access","refresh":"rotated-refresh"}"#) } + if request.url?.path == "/api/v1/auth/device/refresh" { return self.json(DeviceRefreshFixture.rotated) } if request.url?.path == "/api/v1/auth/me" { return (503, ["Content-Type": "application/json", "Retry-After": "17"], Data(#"{"detail":"Identity is temporarily unavailable."}"#.utf8)) } if request.url?.path == "/api/v1/auth/mfa" { return self.json(DebugAccountFixtures.mfa) } return self.json(#"{"detail":"Password verification failed."}"#, status: 401) @@ -332,19 +349,20 @@ private actor AccountScopeGate { private final class AccountResponseGate: @unchecked Sendable { private let lock = NSLock() private let response = DispatchSemaphore(value: 0) + private let entryOrCompletion: XCTestExpectation private var started = false - private var observer: CheckedContinuation? + private var signaled = false + init(entryOrCompletion: XCTestExpectation) { self.entryOrCompletion = entryOrCompletion } + var didEnter: Bool { lock.lock(); defer { lock.unlock() }; return started } func block() { - lock.lock(); started = true; let observer = self.observer; self.observer = nil; lock.unlock() - observer?.resume() - _ = response.wait(timeout: .now() + 10) + lock.lock(); started = true; lock.unlock() + signal() + XCTAssertEqual(response.wait(timeout: .now() + 10), .success, "Held management response was not released") } - func entered() async { - await withCheckedContinuation { continuation in - lock.lock() - if started { lock.unlock(); continuation.resume() } - else { observer = continuation; lock.unlock() } - } + func operationCompleted() { signal() } + private func signal() { + lock.lock(); let shouldSignal = !signaled; signaled = true; lock.unlock() + if shouldSignal { entryOrCompletion.fulfill() } } func release() { response.signal() } } diff --git a/SpaceWorksTests/AuthTests.swift b/SpaceWorksTests/AuthTests.swift index 5232e72..79a991a 100644 --- a/SpaceWorksTests/AuthTests.swift +++ b/SpaceWorksTests/AuthTests.swift @@ -223,7 +223,7 @@ final class MemoryTokens: TokenStorage, @unchecked Sendable { return (200, DebugAuthFixtures.user.replacingOccurrences(of: "\"must_change_password\":false", with: "\"must_change_password\":true")) } if request.url!.path.hasSuffix("/change-password") { return (401, #"{"detail":"Current password is incorrect."}"#) } - if request.url!.path.hasSuffix("/refresh") { return (200, #"{"access":"rotated-access","refresh":"rotated-refresh"}"#) } + if request.url!.path.hasSuffix("/refresh") { return (200, DeviceRefreshFixture.rotated) } return try self.response(request) } await auth.restore(); XCTAssertEqual(auth.stage, .passwordChange) diff --git a/SpaceWorksTests/DeviceRefreshFixture.swift b/SpaceWorksTests/DeviceRefreshFixture.swift new file mode 100644 index 0000000..1adb356 --- /dev/null +++ b/SpaceWorksTests/DeviceRefreshFixture.swift @@ -0,0 +1,9 @@ +import Foundation +@testable import SpaceWorks + +/// A successful native refresh must include the authoritative device grant. +enum DeviceRefreshFixture { + static var rotated: String { + "{\"access\":\"rotated-access\",\"refresh\":\"rotated-refresh\",\"device_grant\":\(DebugAuthFixtures.grant)}" + } +} diff --git a/SpaceWorksTests/EmailOwnerTests.swift b/SpaceWorksTests/EmailOwnerTests.swift index 159c3a4..5670730 100644 --- a/SpaceWorksTests/EmailOwnerTests.swift +++ b/SpaceWorksTests/EmailOwnerTests.swift @@ -160,7 +160,7 @@ private final class EmailOwnerServer: @unchecked Sendable { case "/api/v1/auth/me": body = DebugAuthFixtures.user case "/api/v1/auth/device/attestation-challenge": body = #"{"challenge":"fixture-challenge","expires_in":120}"# case "/api/v1/auth/device/login": body = DebugAuthFixtures.tokens.replacingOccurrences(of: "test-access", with: "new-access").replacingOccurrences(of: "test-refresh", with: "new-refresh") - case "/api/v1/auth/device/refresh": body = #"{"access":"rotated-access","refresh":"rotated-refresh"}"# + case "/api/v1/auth/device/refresh": body = DeviceRefreshFixture.rotated case Self.confirm, Self.resend: body = #"{"detail":"Accepted."}"# default: XCTFail("Unexpected email-owner API call: " + path); source.respond(status: 500, json: "{}"); return } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c8ddad5..e301cf5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,11 @@ # Phase notes +## A7 native test fixture correction + +- Canceled the previous A7 validation after native diagnostics showed an incomplete successful-refresh fixture prevented the held management retry from being reached. No UI tests had started; that run is not acceptance evidence. +- Share a schema-complete refresh response across account, email and required-password tests, and decode it through the generated `DeviceRefreshResponse` in the existing operation test. The stale-management test now waits at most five seconds for retry arrival or early completion, always releases its held response, and awaits the operation so early decoding errors surface. +- Intended native/UI/Python counts remain 285/34/21. Full phone/tablet tests and unsigned Release must pass at the corrected head before A7 acceptance. + ## A7 Account and localization — implementation checkpoint (2026-10-17) The initial [`d05dcfc` native checkpoint](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/39017485631) stopped during compilation before tests: the localized staff menu wrapper accepted only a localization key, while a booking row supplied its server-provided space name. The wrapper now accepts a Text presentation: app-owned literal titles stay localized and the remote name is explicitly verbatim. The complete native gate remains pending; this compiler checkpoint is not acceptance. From 988b8ad0eb11cacf74ea2519bf91902c3d27929a Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:57:00 +0530 Subject: [PATCH 45/98] test(a8): preserve integer boundary proof across localized grouping Verify the exact representable countdown digits through Character.wholeNumberValue while preserving English retry framing. Retain all overflow/nonfinite cases and production formatting. Record the native checkpoint honestly; full six-profile acceptance remains pending. Co-Authored-By: Codex --- SpaceWorksTests/CooldownCopyTests.swift | 7 ++++++- docs/CHANGELOG.md | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/SpaceWorksTests/CooldownCopyTests.swift b/SpaceWorksTests/CooldownCopyTests.swift index b302837..133da76 100644 --- a/SpaceWorksTests/CooldownCopyTests.swift +++ b/SpaceWorksTests/CooldownCopyTests.swift @@ -21,6 +21,11 @@ final class CooldownCopyTests: XCTestCase { XCTAssertEqual(CooldownCopy.retryMessage(until: deadline, now: now), "Try again later.") } let representable = Date(timeIntervalSince1970: Double(Int.max).nextDown) - XCTAssertEqual(CooldownCopy.retryMessage(until: representable, now: now), "Try again in 9223372036854774784s.") + let rendered = CooldownCopy.retryMessage(until: representable, now: now) + XCTAssertTrue(rendered.hasPrefix("Try again in ")) + XCTAssertTrue(rendered.hasSuffix("s.")) + // Native localized integer interpolation may insert grouping separators. + let decimalDigits = rendered.compactMap(\.wholeNumberValue).map { String($0) }.joined() + XCTAssertEqual(decimalDigits, "9223372036854774784") } } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 16c9850..8363a8b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## A8 Usage guide and release handoff — implementation checkpoint (2026-10-17) +The `32c69d9` phone native checkpoint compiled and executed all 294 native cases. One A8 boundary-copy expectation failed because native localized integer interpolation correctly inserted digit-grouping separators. The test now checks the exact representable decimal value independently of grouping (including non-ASCII decimal digits), while retaining the expected English retry prefix/suffix and every invalid/nonfinite/overflow case. Production formatting and deadlines are unchanged. All shared A7 account/auth/email ownership cases passed in this checkpoint; complete A7 and corrected A8 native/UI/Release acceptance remain pending. + The first merged A8 [`7455146` matrix](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/39018807936) stopped during compilation before tests because SwiftUI exposes Reduce Motion as a read-only environment value. The introduction now reads the OS value and combines it locally with the Debug-only UI-test launch flag; Release continues to use the OS setting. The global environment is unchanged, including introduction replay. This compiler checkpoint provides no native test or screenshot acceptance; the corrected exact-head matrix remains required. The diagnostic `96c25e6` matrix was canceled after its started profiles all stopped in the same account retry test before UI testing. It includes no native acceptance. The candidate now normally merges actual corrected dev `e94a5c8`, including complete successful-refresh fixtures and bounded test synchronization; A7 and A8 native acceptance remain pending. From a1ad7b26490c80ae20ccb24ce4178ffe810eed07 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:54:02 +0530 Subject: [PATCH 46/98] fix(a7): adapt accessible account rows and keyboard journeys Co-Authored-By: Codex --- SpaceWorks/AccountViews.swift | 19 +++++-- SpaceWorksUITests/AccountFlowTests.swift | 71 ++++++++++++++++++++++-- docs/CHANGELOG.md | 7 +++ 3 files changed, 88 insertions(+), 9 deletions(-) diff --git a/SpaceWorks/AccountViews.swift b/SpaceWorks/AccountViews.swift index 5183b15..9a4e5ae 100644 --- a/SpaceWorks/AccountViews.swift +++ b/SpaceWorks/AccountViews.swift @@ -61,11 +61,22 @@ enum AccountLinks { private struct AccountMenuRow: View { let title: LocalizedStringKey; let detail: String; let icon: String + @Environment(\.dynamicTypeSize) private var dynamicTypeSize var body: some View { - HStack(alignment: .top, spacing: 16) { - Image(systemName: icon).font(.title2).accessibilityHidden(true) - VStack(alignment: .leading, spacing: 8) { Text(title).font(.headline); Text(verbatim: detail).font(.subheadline).foregroundStyle(Color.swMuted) } - Spacer(minLength: 0); Image(systemName: "chevron.right").accessibilityHidden(true) + Group { + if dynamicTypeSize.isAccessibilitySize { + // Decorative glyphs must not squeeze scaled words into narrow columns. + VStack(alignment: .leading, spacing: 8) { + Text(title).font(.headline).fixedSize(horizontal: false, vertical: true) + Text(verbatim: detail).font(.subheadline).foregroundStyle(Color.swMuted).fixedSize(horizontal: false, vertical: true) + }.frame(maxWidth: .infinity, alignment: .leading) + } else { + HStack(alignment: .top, spacing: 16) { + Image(systemName: icon).font(.title2).accessibilityHidden(true) + VStack(alignment: .leading, spacing: 8) { Text(title).font(.headline); Text(verbatim: detail).font(.subheadline).foregroundStyle(Color.swMuted) } + Spacer(minLength: 0); Image(systemName: "chevron.right").accessibilityHidden(true) + } + } }.foregroundStyle(Color.swInk).padding(.vertical, 12).frame(minHeight: 64) } } diff --git a/SpaceWorksUITests/AccountFlowTests.swift b/SpaceWorksUITests/AccountFlowTests.swift index d1d04d8..f51a645 100644 --- a/SpaceWorksUITests/AccountFlowTests.swift +++ b/SpaceWorksUITests/AccountFlowTests.swift @@ -35,15 +35,76 @@ final class AccountFlowTests: XCTestCase { attach("Native account confirmation") action.tap() } + private func openDataExports(in app: XCUIApplication, captureBeforeTap: Bool = false) { + let entry = app.buttons["account-exports"] + XCTAssertTrue(entry.waitForExistence(timeout: 5)) + for attempt in 0..<24 { + let scrolls = app.scrollViews.allElementsBoundByIndex.filter { + $0.exists && $0.isHittable && $0.buttons.matching(identifier: "account-exports").firstMatch.exists + } + guard let scroll = scrolls.min(by: { $0.frame.width * $0.frame.height < $1.frame.width * $1.frame.height }) else { + XCTFail("The account menu must provide its real scroll container."); return + } + var viewport = scroll.frame.intersection(app.frame) + for bar in app.navigationBars.allElementsBoundByIndex where bar.exists && bar.isHittable { + let frame = bar.frame + if frame.intersects(viewport), frame.midY < viewport.midY { + let bottom = viewport.maxY; viewport.origin.y = max(viewport.minY, frame.maxY); viewport.size.height = max(0, bottom - viewport.minY) + } + } + let lowerBars = app.tabBars.allElementsBoundByIndex.filter { $0.exists && $0.isHittable } + + app.keyboards.allElementsBoundByIndex.filter { $0.exists } + for bar in lowerBars { + let frame = bar.frame + if frame.intersects(viewport), frame.midY > viewport.minY { viewport.size.height = max(0, min(viewport.maxY, frame.minY) - viewport.minY) } + } + viewport = viewport.insetBy(dx: 8, dy: 8) + let frame = entry.frame, visible = frame.intersection(viewport) + print("Account exports navigation, attempt \(attempt), frame \(frame), viewport \(viewport), hittable \(entry.isHittable)") + guard frame.minX.isFinite, frame.minY.isFinite, frame.width.isFinite, frame.height.isFinite, frame.width > 0, frame.height > 0, + viewport.width > 32, viewport.height > 64 else { XCTFail("Account navigation requires valid visible geometry."); return } + if entry.isHittable, !visible.isNull, visible.width > 4, visible.height > 4, + viewport.insetBy(dx: 2, dy: 2).contains(CGPoint(x: frame.midX, y: frame.midY)) { + if captureBeforeTap { attach("Account menu export navigation at largest text") } + entry.tap(); return + } + // This long menu can exceed twelve short pans at full Dynamic Type. + // Follow the actual row, with the same total bound and real content gestures. + let down: Bool + if frame.midY >= viewport.maxY - 2 { down = false } + else if frame.midY <= viewport.minY + 2 { down = true } + else { continue } + let remaining = down ? viewport.minY + 8 - frame.midY : frame.midY - (viewport.maxY - 8) + let distance = min(viewport.height - 32, max(12, remaining + 12)) + let x = min(viewport.maxX - 16, max(viewport.minX + 16, frame.midX)) + let y = down ? viewport.minY + 16 : viewport.maxY - 16 + let start = app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: x - app.frame.minX, dy: y - app.frame.minY)) + let end = app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: x - app.frame.minX, dy: y + (down ? distance : -distance) - app.frame.minY)) + start.press(forDuration: 0.1, thenDragTo: end, withVelocity: XCUIGestureVelocity(250), thenHoldForDuration: 0.3) + } + XCTFail("The actual data export row must be visible and hittable after bounded account-menu navigation.") + } + private func dismissAccountKeyboard(in app: XCUIApplication) { + // The floating keyboard accessory need not have a Toolbar AX ancestor. + let done = app.buttons["Done"] + XCTAssertTrue(done.waitForExistence(timeout: 5)) + XCTAssertTrue(done.isHittable) + let frame = done.frame + XCTAssertGreaterThan(frame.width, 0) + XCTAssertGreaterThan(frame.height, 0) + XCTAssertTrue(app.frame.insetBy(dx: 2, dy: 2).contains(frame)) + done.tap() + XCTAssertTrue(app.keyboards.firstMatch.waitForNonExistence(timeout: 5), "The keyboard must close before continuing the account flow.") + } private func attach(_ name: String) { let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); attachment.name = name; attachment.lifetime = .keepAlways; add(attachment) } func testPhoneVerificationThenCurrentSessionRevocationRequiresSignIn() { let app = account(); tap(app.buttons["account-phone"], in: app) let phone = app.textFields["account-phone-number"]; tap(phone, in: app); phone.typeText("9876543210") - tap(app.toolbars.buttons["Done"], in: app); tap(app.buttons["account-phone-send"], in: app) + dismissAccountKeyboard(in: app); tap(app.buttons["account-phone-send"], in: app) let code = app.textFields["account-phone-code"]; tap(code, in: app); code.typeText("123456") - tap(app.toolbars.buttons["Done"], in: app); tap(app.buttons["account-phone-verify"], in: app) + dismissAccountKeyboard(in: app); tap(app.buttons["account-phone-verify"], in: app) XCTAssertTrue(app.staticTexts["+919876543210"].waitForExistence(timeout: 10)); attach("Account verified phone sample") tap(app.navigationBars.buttons.element(boundBy: 0), in: app) tap(app.buttons["account-sessions"], in: app); tap(app.buttons["account-revoke-current-session"], in: app) @@ -62,7 +123,7 @@ final class AccountFlowTests: XCTestCase { XCTAssertTrue(app.textFields["demo-username"].waitForExistence(timeout: 10)) } func testExportRequestStatusAndNativeArchivePresentation() { - let app = account(); tap(app.buttons["account-exports"], in: app) + let app = account(); openDataExports(in: app) tap(app.buttons["account-request-export"], in: app) XCTAssertTrue(app.buttons["account-check-export"].waitForExistence(timeout: 10)) tap(app.buttons["account-check-export"], in: app); tap(app.buttons["account-download-export"], in: app) @@ -72,7 +133,7 @@ final class AccountFlowTests: XCTestCase { } func testLargestTextLandscapeExportCanReachNativeShare() { let app = account(extra: ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]) - tap(app.buttons["account-exports"], in: app); tap(app.buttons["account-request-export"], in: app) + openDataExports(in: app, captureBeforeTap: true); tap(app.buttons["account-request-export"], in: app) tap(app.buttons["account-check-export"], in: app); tap(app.buttons["account-download-export"], in: app) XCTAssertTrue(app.navigationBars["Data export"].waitForExistence(timeout: 10)) XCUIDevice.shared.orientation = .landscapeLeft @@ -119,7 +180,7 @@ final class AccountFlowTests: XCTestCase { let app = account(); tap(app.buttons["account-membership"], in: app) let reason = app.textViews["account-membership-reason"].exists ? app.textViews["account-membership-reason"] : app.textFields["account-membership-reason"] tap(reason, in: app); reason.typeText("Moving away") - if app.toolbars.buttons["Done"].exists { tap(app.toolbars.buttons["Done"], in: app) } + dismissAccountKeyboard(in: app) tap(app.buttons["account-leave-makerspace"], in: app) confirmNativeDialog("Leave this makerspace?", action: "Confirm leave", in: app) XCTAssertTrue(app.buttons["demo-space-1"].waitForExistence(timeout: 10)); XCTAssertFalse(app.buttons["demo-space-0"].exists) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e301cf5..9d1f797 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,12 @@ # Phase notes +## A7 accessible account rows and keyboard journeys + +- The corrected phone and tablet runs passed all 285 native tests; tablet passed 33/34 UI journeys and phone passed 32/34. Recordings show a real largest-text layout defect: decorative row glyphs squeeze account titles into fragmented words. Give the unchanged title and detail the full row width at accessibility sizes, preserving font scaling. +- The phone-linking test missed a visible floating Done accessory by restricting its lookup to Toolbar descendants; the compact membership journey also skipped that accessory. Require the actual visible/hittable full-window Done button, tap it natively and verify keyboard dismissal before continuing all three original steps. Keep the shared scroll helper, gesture budget and every phone/session/membership assertion unchanged. +- Correct only the export-menu test navigation's proven blind reversal: follow the real row geometry with full visible-content pans and the same 24-attempt bound, positive/visible/hittable activation and native tap. Capture the revealed accessible row in the existing largest-text journey. The shared helper and every subsequent export/share assertion remain unchanged. +- Counts remain 285 native /34 UI /21 Python. Full phone/tablet validation and unsigned Release are still required at the corrected head; these failed runs are not phase acceptance. + ## A7 native test fixture correction - Canceled the previous A7 validation after native diagnostics showed an incomplete successful-refresh fixture prevented the held management retry from being reached. No UI tests had started; that run is not acceptance evidence. From bdc4add8c4b55eca5432ac613880e41a231bc404 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:02:40 +0530 Subject: [PATCH 47/98] fix(a8): give accessible inventory and staff rows full text width Extend the reviewed account-row layout treatment to the same decorative-glyph pressure in inventory and staff menus. Preserve ordinary layouts, full Dynamic Type, text, native actions and server-title semantics. Record source-audit scope without claiming screenshot failure. All40 Python checks/catalog779/lint and fresh actual-dev helper reproduction pass; full294native/36UI matrix remains required. Co-Authored-By: Codex --- SpaceWorks/MemberViews.swift | 21 ++++++++++++++------- SpaceWorks/StaffViews.swift | 18 ++++++++++++++---- docs/CHANGELOG.md | 2 ++ 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/SpaceWorks/MemberViews.swift b/SpaceWorks/MemberViews.swift index 9d9c4b5..74b2642 100644 --- a/SpaceWorks/MemberViews.swift +++ b/SpaceWorks/MemberViews.swift @@ -280,15 +280,22 @@ struct InventoryList: View { } private func productRow(_ product: Components.Schemas.PublicProduct) -> some View { SWCard { - HStack(alignment: .top, spacing: 16) { - Image(systemName: "shippingbox").font(.title).frame(width: 48, height: 48).background(Color.swSurface, in: RoundedRectangle(cornerRadius: 10)).accessibilityHidden(true) + if textSize.isAccessibilitySize { VStack(alignment: .leading, spacing: 8) { - Text(product.name).font(.headline) - if let category = product.category_name { Text(category).foregroundStyle(Color.swMuted) } - Text(availability(product)).foregroundStyle(Color.swMuted) - + Text(verbatim: product.name).font(.headline).fixedSize(horizontal: false, vertical: true) + if let category = product.category_name { Text(verbatim: category).foregroundStyle(Color.swMuted).fixedSize(horizontal: false, vertical: true) } + Text(availability(product)).foregroundStyle(Color.swMuted).fixedSize(horizontal: false, vertical: true) + }.frame(maxWidth: .infinity, alignment: .leading) + } else { + HStack(alignment: .top, spacing: 16) { + Image(systemName: "shippingbox").font(.title).frame(width: 48, height: 48).background(Color.swSurface, in: RoundedRectangle(cornerRadius: 10)).accessibilityHidden(true) + VStack(alignment: .leading, spacing: 8) { + Text(verbatim: product.name).font(.headline) + if let category = product.category_name { Text(verbatim: category).foregroundStyle(Color.swMuted) } + Text(availability(product)).foregroundStyle(Color.swMuted) + } + Spacer(minLength: 0); Image(systemName: "chevron.right").accessibilityHidden(true) } - Spacer(minLength: 0); Image(systemName: "chevron.right").accessibilityHidden(true) } } } diff --git a/SpaceWorks/StaffViews.swift b/SpaceWorks/StaffViews.swift index 66ce339..bf9fa24 100644 --- a/SpaceWorks/StaffViews.swift +++ b/SpaceWorks/StaffViews.swift @@ -31,11 +31,21 @@ struct StaffView: View { private struct StaffMenuRow: View { let title: Text; let detail: LocalizedStringKey; let symbol: String + @Environment(\.dynamicTypeSize) private var dynamicTypeSize var body: some View { - HStack(alignment: .top, spacing: 16) { - Image(systemName: symbol).font(.title2).accessibilityHidden(true) - VStack(alignment: .leading, spacing: 8) { title.font(.headline); Text(detail).font(.subheadline).foregroundStyle(Color.swMuted) } - Spacer(minLength: 0); Image(systemName: "chevron.right").accessibilityHidden(true) + Group { + if dynamicTypeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 8) { + title.font(.headline).fixedSize(horizontal: false, vertical: true) + Text(detail).font(.subheadline).foregroundStyle(Color.swMuted).fixedSize(horizontal: false, vertical: true) + }.frame(maxWidth: .infinity, alignment: .leading) + } else { + HStack(alignment: .top, spacing: 16) { + Image(systemName: symbol).font(.title2).accessibilityHidden(true) + VStack(alignment: .leading, spacing: 8) { title.font(.headline); Text(detail).font(.subheadline).foregroundStyle(Color.swMuted) } + Spacer(minLength: 0); Image(systemName: "chevron.right").accessibilityHidden(true) + } + } }.frame(minHeight: 64).padding(.vertical, 12).foregroundStyle(Color.swInk) } } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d30c5f0..8556cd8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## A8 Usage guide and release handoff — implementation checkpoint (2026-10-17) +A follow-up source audit found the same decorative-glyph width pressure in the staff menu and inventory rows as the recorded Account row defect. At accessibility Dynamic Type sizes these two rows now give their unchanged title/detail content the full width; ordinary-size layouts, fonts, scaling, server text, semantics and navigation are retained. This is a source-audit correction, not a claim that a staff or inventory screenshot failed. The prior `ba22d5f` matrix is diagnostic once superseded; the corrected exact-head full matrix and actual row visuals remain required. + The `988b8ad` compact checkpoint passed all 294 native tests and executed all 36 UI journeys. Five UI failures identified three shared account navigation/layout issues plus two A8 paragraph queries exceeding XCTest's 128-character identifier limit. This candidate normally merges actual dev `a1ad7b2` for the reviewed account corrections. The two guide checks now use exact full-label predicates, preserving their complete paragraph assertions without identifier-length lookup. The old matrix was canceled for diagnosis and is not acceptance; introduction replay and typed bookings actions after those checks still require the new complete run. All cases, six profiles, gesture budgets and timeout settings remain unchanged. The `32c69d9` phone native checkpoint compiled and executed all 294 native cases. One A8 boundary-copy expectation failed because native localized integer interpolation correctly inserted digit-grouping separators. The test now checks the exact representable decimal value independently of grouping (including non-ASCII decimal digits), while retaining the expected English retry prefix/suffix and every invalid/nonfinite/overflow case. Production formatting and deadlines are unchanged. All shared A7 account/auth/email ownership cases passed in this checkpoint; complete A7 and corrected A8 native/UI/Release acceptance remain pending. From 3193b087bfc1871cfdb24d39c0c74314482f3c8e Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:52:11 +0530 Subject: [PATCH 48/98] fix(a7): expose native accessible keyboard dismissal Co-Authored-By: Codex --- SpaceWorks/AccountViews.swift | 18 ++++++++++++++++-- SpaceWorksUITests/AccountFlowTests.swift | 12 ++++++------ docs/CHANGELOG.md | 6 ++++++ 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/SpaceWorks/AccountViews.swift b/SpaceWorks/AccountViews.swift index 9a4e5ae..2a6e72d 100644 --- a/SpaceWorks/AccountViews.swift +++ b/SpaceWorks/AccountViews.swift @@ -188,7 +188,14 @@ private struct AccountPhoneView: View { } Button("Remove linked phone", role: .destructive) { unlink = true }.frame(minHeight: 48).disabled(!state.canRequest) }.padding(24).frame(maxWidth: 680).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle("Phone sign-in").navigationBarTitleDisplayMode(.inline).scrollDismissesKeyboard(.interactively) - .toolbar { ToolbarItemGroup(placement: .keyboard) { Spacer(); Button("Done") { focused = nil } } } + .toolbar { + if focused != nil { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { focused = nil } + .accessibilityLabel(Text("Done")).accessibilityIdentifier("account-phone-keyboard-done") + } + } + } .confirmationDialog("Remove phone sign-in?", isPresented: $unlink, titleVisibility: .visible) { Button("Remove phone", role: .destructive) { Task { await state.unlinkPhone() } }; Button("Cancel", role: .cancel) {} } .onDisappear { state.code = "" } } @@ -295,7 +302,14 @@ private struct AccountMembershipView: View { } } }.padding(24).frame(maxWidth: 680).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle("Your membership").navigationBarTitleDisplayMode(.inline).scrollDismissesKeyboard(.interactively) - .toolbar { ToolbarItemGroup(placement: .keyboard) { Spacer(); Button("Done") { reasonFocused = false } } }.task { await state.loadMembership() } + .toolbar { + if reasonFocused { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { reasonFocused = false } + .accessibilityLabel(Text("Done")).accessibilityIdentifier("account-membership-keyboard-done") + } + } + }.task { await state.loadMembership() } .confirmationDialog(decision == "leave" ? "Leave this makerspace?" : "Cancel membership renewal?", isPresented: Binding(get: { decision != nil }, set: { if !$0 { decision = nil } }), titleVisibility: .visible) { Button(decision == "leave" ? "Confirm leave" : "Confirm cancellation", role: .destructive) { let leave = decision == "leave"; decision = nil; Task { if leave { await state.leaveMakerspace() } else { await state.cancelRenewal() } } }; Button("Keep membership", role: .cancel) { decision = nil } } diff --git a/SpaceWorksUITests/AccountFlowTests.swift b/SpaceWorksUITests/AccountFlowTests.swift index f51a645..1c0420a 100644 --- a/SpaceWorksUITests/AccountFlowTests.swift +++ b/SpaceWorksUITests/AccountFlowTests.swift @@ -84,9 +84,9 @@ final class AccountFlowTests: XCTestCase { } XCTFail("The actual data export row must be visible and hittable after bounded account-menu navigation.") } - private func dismissAccountKeyboard(in app: XCUIApplication) { - // The floating keyboard accessory need not have a Toolbar AX ancestor. - let done = app.buttons["Done"] + private func dismissAccountKeyboard(_ identifier: String, in app: XCUIApplication) { + // Activate the real focus-bound navigation item, not the inaccessible accessory. + let done = app.navigationBars.buttons[identifier] XCTAssertTrue(done.waitForExistence(timeout: 5)) XCTAssertTrue(done.isHittable) let frame = done.frame @@ -102,9 +102,9 @@ final class AccountFlowTests: XCTestCase { func testPhoneVerificationThenCurrentSessionRevocationRequiresSignIn() { let app = account(); tap(app.buttons["account-phone"], in: app) let phone = app.textFields["account-phone-number"]; tap(phone, in: app); phone.typeText("9876543210") - dismissAccountKeyboard(in: app); tap(app.buttons["account-phone-send"], in: app) + dismissAccountKeyboard("account-phone-keyboard-done", in: app); tap(app.buttons["account-phone-send"], in: app) let code = app.textFields["account-phone-code"]; tap(code, in: app); code.typeText("123456") - dismissAccountKeyboard(in: app); tap(app.buttons["account-phone-verify"], in: app) + dismissAccountKeyboard("account-phone-keyboard-done", in: app); tap(app.buttons["account-phone-verify"], in: app) XCTAssertTrue(app.staticTexts["+919876543210"].waitForExistence(timeout: 10)); attach("Account verified phone sample") tap(app.navigationBars.buttons.element(boundBy: 0), in: app) tap(app.buttons["account-sessions"], in: app); tap(app.buttons["account-revoke-current-session"], in: app) @@ -180,7 +180,7 @@ final class AccountFlowTests: XCTestCase { let app = account(); tap(app.buttons["account-membership"], in: app) let reason = app.textViews["account-membership-reason"].exists ? app.textViews["account-membership-reason"] : app.textFields["account-membership-reason"] tap(reason, in: app); reason.typeText("Moving away") - dismissAccountKeyboard(in: app) + dismissAccountKeyboard("account-membership-keyboard-done", in: app) tap(app.buttons["account-leave-makerspace"], in: app) confirmNativeDialog("Leave this makerspace?", action: "Confirm leave", in: app) XCTAssertTrue(app.buttons["demo-space-1"].waitForExistence(timeout: 10)); XCTAssertFalse(app.buttons["demo-space-0"].exists) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9d1f797..1baf0c1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,11 @@ # Phase notes +## A7 accessible native keyboard dismissal + +- Both corrected devices passed all 285 native tests and 32/34 UI journeys, including the largest-text export menu and native share. The two remaining phone/membership journeys confirm a real accessibility issue: the floating custom keyboard Done is visibly drawn, but the complete accessibility tree exposes an empty toolbar and no Done element. +- Replace those two custom keyboard toolbar groups with a localized native navigation-bar Done action while the corresponding field is focused. The action clears the existing FocusState. Use a stable per-screen accessibility ID and retain all three strict native activation/keyboard-absence checks and every downstream phone/session/membership assertion. +- Full 285 native /34 UI /21 Python and unsigned Release validation must pass at this corrected source. Prior failed runs remain diagnostic evidence only. + ## A7 accessible account rows and keyboard journeys - The corrected phone and tablet runs passed all 285 native tests; tablet passed 33/34 UI journeys and phone passed 32/34. Recordings show a real largest-text layout defect: decorative row glyphs squeeze account titles into fragmented words. Give the unchanged title and detail the full row width at accessibility sizes, preserving font scaling. From 0cab1e6e17db7cd91cb5fc5ac8de1b5db17ccace Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:04:13 +0530 Subject: [PATCH 49/98] test(a8): use actual guide fixture and safe native tab navigation Assert the unchanged existing booking fixture without submitting the retained draft. Match actual tab Buttons and permit only Show/Toggle Sidebar fallback, preserving readiness, native actions, gesture budgets and downstream assertions. Retain observation-only inventory/staff/Done screenshots in existing journeys. All40 Python/catalog779/lint checks and exact3193 helper reproduction pass; full294native/36UI remains required. Co-Authored-By: Codex --- SpaceWorksUITests/AccountFlowTests.swift | 1 + SpaceWorksUITests/GuideFlowTests.swift | 2 +- SpaceWorksUITests/MemberActionsFlowTests.swift | 6 +++--- SpaceWorksUITests/MemberFlowTests.swift | 1 + SpaceWorksUITests/StaffFlowTests.swift | 1 + docs/CHANGELOG.md | 6 ++++++ 6 files changed, 13 insertions(+), 4 deletions(-) diff --git a/SpaceWorksUITests/AccountFlowTests.swift b/SpaceWorksUITests/AccountFlowTests.swift index 419e649..c4faaa6 100644 --- a/SpaceWorksUITests/AccountFlowTests.swift +++ b/SpaceWorksUITests/AccountFlowTests.swift @@ -93,6 +93,7 @@ final class AccountFlowTests: XCTestCase { XCTAssertGreaterThan(frame.width, 0) XCTAssertGreaterThan(frame.height, 0) XCTAssertTrue(app.frame.insetBy(dx: 2, dy: 2).contains(frame)) + attach("Native account keyboard action \(identifier)") done.tap() XCTAssertTrue(app.keyboards.firstMatch.waitForNonExistence(timeout: 5), "The keyboard must close before continuing the account flow.") } diff --git a/SpaceWorksUITests/GuideFlowTests.swift b/SpaceWorksUITests/GuideFlowTests.swift index 13ab928..5ef1bac 100644 --- a/SpaceWorksUITests/GuideFlowTests.swift +++ b/SpaceWorksUITests/GuideFlowTests.swift @@ -56,7 +56,7 @@ final class GuideFlowTests: XCTestCase { XCTAssertTrue(app.navigationBars["Book a space"].waitForExistence(timeout: 5)) tab("Home", app: app); tap(app.buttons["View bookings"], in: app) XCTAssertTrue(app.navigationBars["Bookings"].waitForExistence(timeout: 5)) - XCTAssertTrue(app.buttons["Workshop bench"].exists) + XCTAssertTrue(app.buttons["Electronics workbench"].waitForExistence(timeout: 5)) capture("Start here opens bookings above retained activity draft") tab("Home", app: app) makerspace("Maker Space Bengaluru", app: app) diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index bb0af6e..5e6c932 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -200,11 +200,11 @@ final class MemberActionsFlowTests: XCTestCase { } private func tab(_ app: XCUIApplication, _ name: String) { if app.tabBars.buttons[name].exists { app.tabBars.buttons[name].tap(); return } - let item = app.descendants(matching: .any)["nav-\(name)"].firstMatch + let item = app.buttons["nav-\(name)"] let ready = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == true AND isHittable == true"), object: item) if XCTWaiter.wait(for: [ready], timeout: 3) != .completed { - let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'sidebar'")).firstMatch - if sidebar.exists { sidebar.tap() } + let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label ==[c] %@ OR label ==[c] %@", "Show Sidebar", "Toggle Sidebar")).firstMatch + if sidebar.exists && sidebar.isHittable { sidebar.tap() } } tap(item, in: app) } diff --git a/SpaceWorksUITests/MemberFlowTests.swift b/SpaceWorksUITests/MemberFlowTests.swift index 7671fd3..4d0a8ed 100644 --- a/SpaceWorksUITests/MemberFlowTests.swift +++ b/SpaceWorksUITests/MemberFlowTests.swift @@ -30,6 +30,7 @@ final class MemberFlowTests: XCTestCase { let product = app.buttons["product-1"].firstMatch let link = app.descendants(matching: .any)["product-1"].firstMatch XCTAssertTrue(link.waitForExistence(timeout: 5)) + capture("Inventory list before item activation") if product.exists { product.tap() } else { link.tap() } XCTAssertTrue(app.staticTexts["About this item"].waitForExistence(timeout: 5)) capture("Inventory item detail") diff --git a/SpaceWorksUITests/StaffFlowTests.swift b/SpaceWorksUITests/StaffFlowTests.swift index 9bf1940..5ff4628 100644 --- a/SpaceWorksUITests/StaffFlowTests.swift +++ b/SpaceWorksUITests/StaffFlowTests.swift @@ -53,6 +53,7 @@ final class StaffFlowTests: XCTestCase { } func testGuidedIssueRequiresBorrowerEvidenceAndFinalConfirmation() { let app = staff() + attach("Staff menu before requests") tap(app.buttons["staff-requests"], in: app); tap(app.buttons["staff-request-queue"], in: app) tap(app.buttons["Ready for issue"], in: app); tap(app.buttons["staff-request-602"], in: app) tap(app.buttons["Start guided issue"], in: app); tap(app.buttons["Scan handover code"], in: app) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index db91c3e..8ffcbd2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,12 @@ ## A8 Usage guide and release handoff — implementation checkpoint (2026-10-17) +Existing inventory, staff and account-keyboard journeys now retain named full-OS screenshots immediately before their already-required native activations. These observation-only captures permit final-source row and Done-button review without relying on success recordings; they add no test methods, actions or sensitive recovery/password captures. + +The same canceled large-tablet log showed the MemberActions test tab fallback tapping Hide Sidebar and then looking for a missing Activity control while scrolling Home. Its test-only tab query now targets the actual native Button supplied by the app and, after the unchanged three-second readiness check, may activate only a verified Show Sidebar or Toggle Sidebar control. The initial missing-control cause is not inferred from the log. Native activation, the existing gesture budget and all booking/cancellation assertions remain unchanged. + +Canceled `bdc4add` native logs reached the typed Bookings destination, then exposed an incorrect guide fixture assertion: the unchanged confirmed sample row is Electronics workbench; Workshop bench exists only after a booking mutation, which this draft-preservation journey deliberately never performs. The guide now waits for that exact existing fixture row after the unchanged Bookings-title assertion. This correction follows source and native log evidence; canceled bundles did not export failure media, so it makes no screenshot claim. No new booking, fixture rename or reduced outcome is introduced. + This candidate normally merges reviewed actual dev `3193b08`, replacing a custom keyboard accessory that recordings showed visually but native accessibility could not expose, with focus-bound native navigation-bar Done actions. Its exact per-screen native queries preserve strict hittable/full-window activation and keyboard-dismissal checks. The prior `bdc4add` matrix was canceled after this concrete shared correction; partial results are diagnostic, not acceptance. All A8 guide, row, countdown, branding and split-CI changes remain included; complete exact-head native/UI/Release validation remains required. A follow-up source audit found the same decorative-glyph width pressure in the staff menu and inventory rows as the recorded Account row defect. At accessibility Dynamic Type sizes these two rows now give their unchanged title/detail content the full width; ordinary-size layouts, fonts, scaling, server text, semantics and navigation are retained. This is a source-audit correction, not a claim that a staff or inventory screenshot failed. The prior `ba22d5f` matrix is diagnostic once superseded; the corrected exact-head full matrix and actual row visuals remain required. From 65057234187e77f939a7447caf206834acec8c94 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:58:56 +0530 Subject: [PATCH 50/98] fix(a8): present readable native demo payment outcomes Replace only the Debug outcome popover with a large native scrollable sheet after actual AX5 evidence showed fragmented labels and a hidden cancellation choice. Preserve every outcome/state callback and disclaimer; dismiss before simulation. Reveal actual sheet content with bounded gestures, retain native full-action checks and pending/PDF outcomes, and capture initial choices plus selected-action geometry. All40 Python/catalog781/lint checks and exact helper reproduction pass;294native/36UI full matrix remains required. Co-Authored-By: Codex --- SpaceWorks/PaymentsViews.swift | 44 ++++++++++++++++++---- SpaceWorks/Resources/Localizable.xcstrings | 22 +++++++++++ SpaceWorksUITests/PaymentsFlowTests.swift | 34 ++++++++++++++++- docs/CHANGELOG.md | 2 + 4 files changed, 93 insertions(+), 9 deletions(-) diff --git a/SpaceWorks/PaymentsViews.swift b/SpaceWorks/PaymentsViews.swift index e124162..f0ade7a 100644 --- a/SpaceWorks/PaymentsViews.swift +++ b/SpaceWorks/PaymentsViews.swift @@ -125,13 +125,9 @@ struct PaymentDetailView: View { .background(PaymentPresentationAnchor(context: presentation).frame(width: 0, height: 0)) .task { if state.load == .idle { await state.refresh() } } #if DEBUG - .confirmationDialog("Demo checkout · no money moves", isPresented: $demoChoice, titleVisibility: .visible) { - Button("Preview confirmed payment") { Task { await state.simulateDemo(paymentID: paymentID, scenario: .paid) } } - Button("Preview waiting for confirmation") { Task { await state.simulateDemo(paymentID: paymentID, scenario: .pending) } } - Button("Preview failed checkout") { Task { await state.simulateDemo(paymentID: paymentID, scenario: .failed) } } - Button("Preview canceled checkout") { Task { await state.simulateDemo(paymentID: paymentID, scenario: .cancelled) } } - Button("Cancel", role: .cancel) {} - } message: { Text("These sample results do not open a payment SDK or contact a server.") } + .sheet(isPresented: $demoChoice) { + DemoPaymentOutcomeSheet(state: state, paymentID: paymentID) + } #endif } @ViewBuilder private var checkoutStatus: some View { @@ -175,6 +171,40 @@ struct PaymentDetailView: View { } } +#if DEBUG +private struct DemoPaymentOutcomeSheet: View { + @Bindable var state: PaymentsState + let paymentID: Int + @Environment(\.dismiss) private var dismiss + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + Text("Demo checkout · no money moves").font(.title.bold()).accessibilityAddTraits(.isHeader) + Text("These sample results do not open a payment SDK or contact a server.").foregroundStyle(Color.swMuted) + choice("Preview confirmed payment", scenario: .paid) + choice("Preview waiting for confirmation", scenario: .pending) + choice("Preview failed checkout", scenario: .failed) + choice("Preview canceled checkout", scenario: .cancelled) + }.padding(24).frame(maxWidth: 680, alignment: .leading).frame(maxWidth: .infinity) + }.accessibilityIdentifier("demo-payment-outcome-scroll") + .background(Color.swBg).foregroundStyle(Color.swInk).tint(Color.swAccentInk) + .navigationTitle("Demo checkout").navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel", role: .cancel) { dismiss() } } } + }.presentationDetents([.large]).tint(Color.swAccentInk) + } + private func choice(_ title: LocalizedStringKey, scenario: DemoPaymentScenario) -> some View { + Button { + dismiss() + Task { await state.simulateDemo(paymentID: paymentID, scenario: scenario) } + } label: { + Text(title).font(.headline).fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading).frame(minHeight: 48) + }.buttonStyle(SWPrimary()) + } +} +#endif + struct PaymentDocumentsView: View { @Bindable var state: PaymentsState var paymentID: Int? diff --git a/SpaceWorks/Resources/Localizable.xcstrings b/SpaceWorks/Resources/Localizable.xcstrings index 7c001a4..7421d5b 100644 --- a/SpaceWorks/Resources/Localizable.xcstrings +++ b/SpaceWorks/Resources/Localizable.xcstrings @@ -2025,6 +2025,28 @@ } } }, + "Demo checkout": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo checkout" + } + } + } + }, + "Demo checkout · no money moves": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo checkout · no money moves" + } + } + } + }, "Demo code ready. Use 123456. No email was sent.": { "extractionState": "manual", "localizations": { diff --git a/SpaceWorksUITests/PaymentsFlowTests.swift b/SpaceWorksUITests/PaymentsFlowTests.swift index a01d557..123ba15 100644 --- a/SpaceWorksUITests/PaymentsFlowTests.swift +++ b/SpaceWorksUITests/PaymentsFlowTests.swift @@ -22,17 +22,47 @@ final class PaymentsFlowTests: XCTestCase { tap(app.buttons["payments-entry"], in: app) return app } + private func demoOutcomeViewport(in app: XCUIApplication) -> CGRect { + var viewport = app.scrollViews["demo-payment-outcome-scroll"].frame.intersection(app.frame) + let bar = app.navigationBars["Demo checkout"] + if bar.exists, bar.isHittable, bar.frame.intersects(viewport) { + let bottom = viewport.maxY + viewport.origin.y = max(viewport.minY, bar.frame.maxY) + viewport.size.height = max(0, bottom - viewport.minY) + } + return viewport.insetBy(dx: 2, dy: 2) + } + private func revealDemoOutcome(_ action: XCUIElement, in app: XCUIApplication) { + let scroll = app.scrollViews["demo-payment-outcome-scroll"] + XCTAssertTrue(scroll.waitForExistence(timeout: 5)) + attach("Demo checkout preview choices") + for attempt in 0..<24 { + let viewport = demoOutcomeViewport(in: app), frame = action.frame + print("Demo outcome \(action.label), attempt \(attempt), frame \(frame), viewport \(viewport), hittable \(action.isHittable)") + if action.isHittable, frame.width > 0, frame.height > 0, viewport.contains(frame) { return } + guard viewport.width > 32, viewport.height > 64 else { break } + let down = frame.minY < viewport.minY + let distance = min(200, viewport.height * 0.45) + let x = viewport.midX, y = down ? viewport.minY + 16 : viewport.maxY - 16 + let start = app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: x - app.frame.minX, dy: y - app.frame.minY)) + let end = app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: x - app.frame.minX, dy: y + (down ? distance : -distance) - app.frame.minY)) + start.press(forDuration: 0.1, thenDragTo: end, withVelocity: XCUIGestureVelocity(250), thenHoldForDuration: 0.3) + } + attach("Demo outcome action not fully visible") + XCTFail("The complete native outcome action must be reachable inside its own sheet scroll view.") + } private func chooseDemoOutcome(_ title: String, in app: XCUIApplication) { - // A system confirmation popover exposes separate title/action scroll areas. - // Its flattened AX descendants are not an app-content scroll hierarchy. + // Reveal only the identified native sheet content; never scroll its backing page. XCTAssertTrue(app.staticTexts["Demo checkout · no money moves"].waitForExistence(timeout: 5)) let action = app.buttons[title] XCTAssertTrue(action.waitForExistence(timeout: 5)) + revealDemoOutcome(action, in: app) XCTAssertTrue(action.isHittable) let frame = action.frame XCTAssertGreaterThan(frame.width, 0) XCTAssertGreaterThan(frame.height, 0) XCTAssertTrue(app.frame.insetBy(dx: 2, dy: 2).contains(frame)) + XCTAssertTrue(demoOutcomeViewport(in: app).contains(frame)) attach("Native demo checkout outcome") action.tap() } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8ffcbd2..db94f21 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## A8 Usage guide and release handoff — implementation checkpoint (2026-10-17) +The `0cab1e6` large-tablet checkpoint passed all 294 native cases and 35/36 UI journeys. Its actual largest-text recording showed the native demo payment popover compressing labels and hiding the fourth outcome below its action viewport. Only the Debug outcome picker now uses a large, scrollable native sheet with the same four outcomes and no-money/no-SDK disclaimer; choosing an outcome dismisses the sheet before the unchanged asynchronous simulation. Release/provider/payment-state behavior is unchanged. The existing UI helper reveals the actual identified sheet content with a bounded gesture budget, then retains positive, fully visible, hittable native action checks and the unchanged pending/PDF assertions. Two title keys are now seeded in the English catalog, bringing it to 781 keys. The complete corrected native/UI/Release matrix is still required. + Existing inventory, staff and account-keyboard journeys now retain named full-OS screenshots immediately before their already-required native activations. These observation-only captures permit final-source row and Done-button review without relying on success recordings; they add no test methods, actions or sensitive recovery/password captures. The same canceled large-tablet log showed the MemberActions test tab fallback tapping Hide Sidebar and then looking for a missing Activity control while scrolling Home. Its test-only tab query now targets the actual native Button supplied by the app and, after the unchanged three-second readiness check, may activate only a verified Show Sidebar or Toggle Sidebar control. The initial missing-control cause is not inferred from the log. Native activation, the existing gesture budget and all booking/cancellation assertions remain unchanged. From 64afc3aae1e517cb3e7309d3c83ec904c8aa4a98 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:24:28 +0530 Subject: [PATCH 51/98] fix(a8): clarify native confirmation and destination readiness Use a native presenting-data alert for the two-choice membership decision and preserve strict whole-action native proof. Await actual sidebar/Profile and booking destination readiness before existing actions. Keep renewal action stable without a retry deadline, matching the existing request form; the link between periodic redraw and the recorded lost press remains unproven. Preserve every outcome, all294native/36UI cases, timeout budgets and actual3193 ancestry. All40 checks/catalog781/lint and exact13-path helper reproduction pass; new native acceptance remains required. Co-Authored-By: Codex --- SpaceWorks/AccountViews.swift | 4 +-- SpaceWorks/MemberActionViews.swift | 13 ++++++-- SpaceWorksUITests/AccountFlowTests.swift | 30 ++++++++++++++++--- .../MemberActionsFlowTests.swift | 7 ++++- docs/CHANGELOG.md | 4 +++ 5 files changed, 48 insertions(+), 10 deletions(-) diff --git a/SpaceWorks/AccountViews.swift b/SpaceWorks/AccountViews.swift index ce8c90f..2bba40e 100644 --- a/SpaceWorks/AccountViews.swift +++ b/SpaceWorks/AccountViews.swift @@ -311,8 +311,8 @@ private struct AccountMembershipView: View { } } }.task { await state.loadMembership() } - .confirmationDialog(decision == "leave" ? "Leave this makerspace?" : "Cancel membership renewal?", isPresented: Binding(get: { decision != nil }, set: { if !$0 { decision = nil } }), titleVisibility: .visible) { - Button(decision == "leave" ? "Confirm leave" : "Confirm cancellation", role: .destructive) { let leave = decision == "leave"; decision = nil; Task { if leave { await state.leaveMakerspace() } else { await state.cancelRenewal() } } }; Button("Keep membership", role: .cancel) { decision = nil } + .alert(decision == "leave" ? "Leave this makerspace?" : "Cancel membership renewal?", isPresented: Binding(get: { decision != nil }, set: { if !$0 { decision = nil } }), presenting: decision) { selectedDecision in + Button(selectedDecision == "leave" ? "Confirm leave" : "Confirm cancellation", role: .destructive) { let leave = selectedDecision == "leave"; decision = nil; Task { if leave { await state.leaveMakerspace() } else { await state.cancelRenewal() } } }; Button("Keep membership", role: .cancel) { decision = nil } } } } diff --git a/SpaceWorks/MemberActionViews.swift b/SpaceWorks/MemberActionViews.swift index d2486d4..8d3cc99 100644 --- a/SpaceWorks/MemberActionViews.swift +++ b/SpaceWorks/MemberActionViews.swift @@ -87,15 +87,22 @@ struct RenewLoanView: View { if let due = loan.due_at { Text("Currently due \(due.formatted(date: .abbreviated, time: .shortened))").foregroundStyle(Color.swMuted) } Text("Your makerspace checks its renewal policy. A renewal is confirmed only when a new due date is returned.").foregroundStyle(Color.swMuted) if let error { SWError(message: error) } - TimelineView(.periodic(from: .now, by: 1)) { tick in - Button(busy ? "Checking…" : "Confirm renewal") { Task { await renew() } }.buttonStyle(SWPrimary()) - .disabled(busy || (retryAfter ?? .distantPast) > tick.date) + if retryAfter != nil { + TimelineView(.periodic(from: .now, by: 1)) { tick in + confirmation(at: tick.date) + } + } else { + confirmation(at: .now) } } }.padding(24).frame(maxWidth: 600).frame(maxWidth: .infinity) }.background(Color.swBg).toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() }.frame(minHeight: 48).disabled(busy) } } }.interactiveDismissDisabled(busy) } + private func confirmation(at date: Date) -> some View { + Button(busy ? "Checking…" : "Confirm renewal") { Task { await renew() } }.buttonStyle(SWPrimary()) + .disabled(busy || (retryAfter ?? .distantPast) > date) + } private func renew() async { guard !busy else { return }; busy = true; error = nil; defer { busy = false } do { result = try await repository.renew(request: loan.request_id); await completed() } diff --git a/SpaceWorksUITests/AccountFlowTests.swift b/SpaceWorksUITests/AccountFlowTests.swift index c4faaa6..0425a17 100644 --- a/SpaceWorksUITests/AccountFlowTests.swift +++ b/SpaceWorksUITests/AccountFlowTests.swift @@ -12,10 +12,16 @@ final class AccountFlowTests: XCTestCase { tap(app.buttons["demo-space-0"], in: app) if app.tabBars.buttons["Profile"].exists { tap(app.tabBars.buttons["Profile"], in: app) } else { - let profile = app.descendants(matching: .any)["nav-Profile"].firstMatch - if !profile.isHittable { - let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'sidebar'")).firstMatch - if sidebar.exists { sidebar.tap() } + let profile = app.buttons["nav-Profile"] + let readiness = NSPredicate(format: "exists == true AND isEnabled == true AND isHittable == true") + let ready = XCTNSPredicateExpectation(predicate: readiness, object: profile) + if XCTWaiter.wait(for: [ready], timeout: 3) != .completed { + let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label ==[c] %@ OR label ==[c] %@", "Show Sidebar", "Toggle Sidebar")).firstMatch + let sidebarReady = XCTNSPredicateExpectation(predicate: readiness, object: sidebar) + XCTAssertEqual(XCTWaiter.wait(for: [sidebarReady], timeout: 3), .completed) + sidebar.tap() + let opened = XCTNSPredicateExpectation(predicate: readiness, object: profile) + XCTAssertEqual(XCTWaiter.wait(for: [opened], timeout: 3), .completed) } tap(profile, in: app) } @@ -23,6 +29,22 @@ final class AccountFlowTests: XCTestCase { tap(app.buttons["account-entry"], in: app); return app } private func confirmNativeDialog(_ title: String, action label: String, in app: XCUIApplication) { + if title == "Leave this makerspace?" || title == "Cancel membership renewal?" { + let alert = app.alerts.firstMatch + XCTAssertTrue(alert.waitForExistence(timeout: 5)) + XCTAssertTrue(alert.staticTexts[title].waitForExistence(timeout: 5)) + let action = alert.buttons[label] + XCTAssertTrue(action.waitForExistence(timeout: 5)) + XCTAssertTrue(action.isHittable) + let frame = action.frame + XCTAssertGreaterThan(frame.width, 0) + XCTAssertGreaterThan(frame.height, 0) + XCTAssertTrue(alert.frame.insetBy(dx: 2, dy: 2).contains(frame)) + XCTAssertTrue(app.frame.insetBy(dx: 2, dy: 2).contains(frame)) + attach("Native account confirmation") + action.tap() + return + } // Native confirmation popovers own separate title/action scroll regions. XCTAssertTrue(app.staticTexts[title].waitForExistence(timeout: 5)) let action = app.buttons[label] diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index 5e6c932..1b0e6ea 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -4,6 +4,7 @@ final class MemberActionsFlowTests: XCTestCase { override func setUp() { continueAfterFailure = false; XCUIDevice.shared.orientation = .portrait } private func activate(_ element: XCUIElement) { if element.identifier == "submit-request" { capture("Request review before submission") } + if element.label == "Confirm renewal" { capture("Loan renewal before confirmation") } element.tap() } private func tap(_ element: XCUIElement, in app: XCUIApplication) { @@ -223,7 +224,11 @@ final class MemberActionsFlowTests: XCTestCase { func testAvailabilityBookingPendingReceiptAndServerConfirmedCancellation() { let app = demo(["--demo-action-pending"]); tab(app, "Activity") tap(app.buttons["Find a space"], in: app) - tap(app.descendants(matching: .any)["space-00000000-0000-0000-0000-000000000031"].firstMatch, in: app) + let space = app.descendants(matching: .any)["space-00000000-0000-0000-0000-000000000031"].firstMatch + tap(space, in: app) + let moved = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: space) + XCTAssertEqual(XCTWaiter.wait(for: [moved], timeout: 5), .completed) + XCTAssertTrue(app.navigationBars["Book a space"].waitForExistence(timeout: 5)) tap(app.buttons["Check availability"], in: app) tap(app.buttons["Submit booking request"], in: app) XCTAssertTrue(app.staticTexts["Booking request received"].waitForExistence(timeout: 5)); capture("Space booking awaiting approval") diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index db94f21..f737123 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,10 @@ ## A8 Usage guide and release handoff — implementation checkpoint (2026-10-17) +The `6505723` checkpoint exposed three further concrete readiness/presentation issues. Phone booking reached the correct destination, but helper enumeration still held an outgoing discovery ScrollView; the journey now waits for that selected link to disappear and the actual booking title before reading destination geometry. A successful large-tablet screenshot showed the membership destructive label clipped inside a confirmation popover; that existing two-choice decision now uses a native alert with captured presenting data and strict whole-action alert bounds in its test. Normal-tablet Profile setup now waits for the actual enabled native button and only a verified Show/Toggle Sidebar action before continuing. These preserve every outcome and use no sleeps or retries. + +The tablet renewal recording showed a native center tap and pressed feedback without a result or error. Renewal now mirrors the existing request form: its action is stable when no cooldown exists, and a periodic view exists only for a real retry deadline. Busy/deadline disabling, the operation and result are unchanged. This removes unnecessary periodic redraw; a causal link to the lost press is not established. The existing native tap and Loan renewed assertion remain, with a pre-activation screenshot for direct evidence. Full corrected native/UI/Release and visual acceptance remain required. + The `0cab1e6` large-tablet checkpoint passed all 294 native cases and 35/36 UI journeys. Its actual largest-text recording showed the native demo payment popover compressing labels and hiding the fourth outcome below its action viewport. Only the Debug outcome picker now uses a large, scrollable native sheet with the same four outcomes and no-money/no-SDK disclaimer; choosing an outcome dismisses the sheet before the unchanged asynchronous simulation. Release/provider/payment-state behavior is unchanged. The existing UI helper reveals the actual identified sheet content with a bounded gesture budget, then retains positive, fully visible, hittable native action checks and the unchanged pending/PDF assertions. Two title keys are now seeded in the English catalog, bringing it to 781 keys. The complete corrected native/UI/Release matrix is still required. Existing inventory, staff and account-keyboard journeys now retain named full-OS screenshots immediately before their already-required native activations. These observation-only captures permit final-source row and Done-button review without relying on success recordings; they add no test methods, actions or sensitive recovery/password captures. From 547282b7780d0bbbf49a591f2e87be5754db70d9 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:09:37 +0530 Subject: [PATCH 52/98] Improve native control readiness across accessibility profiles Reveal finite targets without reversing away, require explicit recovery acknowledgment, and keep payment reveal gestures outside outcome buttons. Add a focus-only code keyboard action and bounded input/navigation readiness. Await actual post-launch landscape geometry while retaining strict assertions and every existing case. Co-Authored-By: Codex --- SpaceWorks/AccountAccessView.swift | 11 +++++++ SpaceWorksUITests/AccountFlowTests.swift | 8 +++-- SpaceWorksUITests/MainFlowTests.swift | 15 ++++++---- .../MemberActionsFlowTests.swift | 20 ++++++++----- SpaceWorksUITests/NativeTestLaunch.swift | 9 ++++++ SpaceWorksUITests/NativeUIActions.swift | 30 ++++++++++++------- .../NotificationsFlowTests.swift | 20 ++++++++----- SpaceWorksUITests/PaymentsFlowTests.swift | 5 +++- SpaceWorksUITests/SecurityFlowTests.swift | 11 +++++++ docs/CHANGELOG.md | 4 +++ 10 files changed, 99 insertions(+), 34 deletions(-) diff --git a/SpaceWorks/AccountAccessView.swift b/SpaceWorks/AccountAccessView.swift index 92a55d5..cf227ef 100644 --- a/SpaceWorks/AccountAccessView.swift +++ b/SpaceWorks/AccountAccessView.swift @@ -54,6 +54,17 @@ struct AccountAccessView: View { }.disabled(form.busy) }.padding(24).frame(maxWidth: 508).frame(maxWidth: .infinity) }.background(Color.swBg).scrollDismissesKeyboard(.interactively) + .safeAreaInset(edge: .top, spacing: 0) { + if focused == "account-code" { + HStack { + Spacer() + Button { focused = nil } label: { + Text("Done").frame(minWidth: 48, minHeight: 48).contentShape(Rectangle()) + }.buttonStyle(.plain).accessibilityIdentifier("account-code-keyboard-done") + }.padding(.horizontal, 24).padding(.vertical, 8) + .foregroundStyle(Color.swAccentInk).background(Color.swSurface) + } + } .interactiveDismissDisabled(form.busy) .onDisappear { form.clearSecrets() } } diff --git a/SpaceWorksUITests/AccountFlowTests.swift b/SpaceWorksUITests/AccountFlowTests.swift index 0425a17..a0cee93 100644 --- a/SpaceWorksUITests/AccountFlowTests.swift +++ b/SpaceWorksUITests/AccountFlowTests.swift @@ -142,7 +142,11 @@ final class AccountFlowTests: XCTestCase { confirmNativeDialog("Replace your recovery codes?", action: "Continue", in: app) XCTAssertTrue(app.staticTexts["Save your recovery codes"].waitForExistence(timeout: 10)) let next = app.buttons["account-security-continue"]; XCTAssertFalse(next.isEnabled) - tap(app.switches["account-recovery-saved"], in: app); tap(next, in: app) + let saved = app.switches["account-recovery-saved"] + NativeUIActions.tap(saved, in: app, requireWholeFrame: true) + XCTAssertEqual(saved.value as? String, "1") + XCTAssertTrue(next.isEnabled) + NativeUIActions.tap(next, in: app, requireWholeFrame: true) XCTAssertTrue(app.textFields["demo-username"].waitForExistence(timeout: 10)) } func testExportRequestStatusAndNativeArchivePresentation() { @@ -159,7 +163,7 @@ final class AccountFlowTests: XCTestCase { openDataExports(in: app, captureBeforeTap: true); tap(app.buttons["account-request-export"], in: app) tap(app.buttons["account-check-export"], in: app); tap(app.buttons["account-download-export"], in: app) XCTAssertTrue(app.navigationBars["Data export"].waitForExistence(timeout: 10)) - XCUIDevice.shared.orientation = .landscapeLeft + app.rotateToLandscapeAndWait() XCTAssertGreaterThan(app.frame.width, app.frame.height) // NativeUIActions requires a real visible/hittable action after bounded scrolling. tap(app.buttons["account-share-export"], in: app) diff --git a/SpaceWorksUITests/MainFlowTests.swift b/SpaceWorksUITests/MainFlowTests.swift index b22f514..5c63a35 100644 --- a/SpaceWorksUITests/MainFlowTests.swift +++ b/SpaceWorksUITests/MainFlowTests.swift @@ -40,7 +40,7 @@ final class MainFlowTests: XCTestCase { credentials(app); selectSpace(app) XCTAssertTrue(app.staticTexts["Welcome back, Maker."].waitForExistence(timeout: 10)) attach(app, "Home member portrait") - XCUIDevice.shared.orientation = .landscapeLeft + app.rotateToLandscapeAndWait() XCTAssertGreaterThan(app.frame.width, app.frame.height) for name in ["Inventory", "Activity", "Inbox", "Profile"] { tab(app, name) } XCTAssertTrue(app.buttons["Sign out"].waitForExistence(timeout: 5)) @@ -52,9 +52,14 @@ final class MainFlowTests: XCTestCase { } func testMFAContinuesToMakerspaceSelection() { let app = launch(extra: ["--mfa"]); connect(app); credentials(app) - XCTAssertTrue(app.textFields["mfa-code"].waitForExistence(timeout: 10)) - app.textFields["mfa-code"].tap(); app.textFields["mfa-code"].typeText("123456\n") - app.buttons["verify"].tap(); selectSpace(app) + let code = app.textFields["mfa-code"] + XCTAssertTrue(code.waitForExistence(timeout: 10)) + NativeUIActions.tap(code, in: app) + XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5)) + attach(app, "MFA authentication code ready") + code.typeText("123456\n") + XCTAssertEqual(code.value as? String, "123456") + NativeUIActions.tap(app.buttons["verify"], in: app); selectSpace(app) XCTAssertTrue(app.staticTexts["Welcome back, Maker."].waitForExistence(timeout: 10)) } func testServerFailureRemainsReviewable() { @@ -67,7 +72,7 @@ final class MainFlowTests: XCTestCase { let app = launch(extra: ["--ui-start-light", "--ui-start-portrait", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]) app.buttons["appearance"].tap(); connect(app) app.swipeUp(); attach(app, "Sign in largest text dark portrait") - XCUIDevice.shared.orientation = .landscapeLeft + app.rotateToLandscapeAndWait() XCTAssertGreaterThan(app.frame.width, app.frame.height) app.swipeDown(); XCTAssertTrue(app.textFields["username"].exists) app.swipeUp(); XCTAssertTrue(app.buttons["sign-in"].exists) diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index 1b0e6ea..24e4ed8 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -52,10 +52,9 @@ final class MemberActionsFlowTests: XCTestCase { element.isHittable, !visible.isNull, visible.width > 8, visible.height > 8 { element.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: visible.midX - frame.minX, dy: visible.midY - frame.minY)).tap(); return } - // Off-screen accessibility frames can jump across the viewport after - // rotation. Reversing on their midpoint oscillated around hidden controls. - // Sweep consistently in each direction; activation still requires a real - // visible, hittable target. New screens normally start near their top. + // Follow observed target geometry while it remains available. A fixed + // half-budget reversal can move a still-offscreen control away again. + // Missing or unusable targets retain the bounded two-direction sweep. if scroll.identifier == "request-review-scroll" { // An unchanged far-offscreen target demonstrates a no-op coordinate // pan. Exercise a native scroll gesture within this same search budget. @@ -80,7 +79,14 @@ final class MemberActionsFlowTests: XCTestCase { else if frame.maxY > viewport.maxY { reviewDown = false } } drag(scroll, app, target: element, down: reviewDown) - } else { drag(scroll, app, target: element, down: attempt >= 12) } + } else { + var down = attempt >= 12 + if frame.minY.isFinite, frame.maxY.isFinite, frame.height > 0 { + if frame.maxY <= viewport.minY || frame.minY < viewport.minY { down = true } + else if frame.minY >= viewport.maxY || frame.maxY > viewport.maxY { down = false } + } + drag(scroll, app, target: element, down: down) + } } else { drag(scroll, app, target: element, down: scroll.identifier == "request-review-scroll" ? reviewDown : attempt >= 12) } @@ -255,7 +261,7 @@ final class MemberActionsFlowTests: XCTestCase { let app = demo(); tab(app, "Profile"); tap(app.buttons["Edit profile"], in: app) let name = app.textFields["profile-display-name"]; tap(name, in: app) let old = name.value as? String ?? ""; name.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: old.count) + "Meera Maker\n") - XCUIDevice.shared.orientation = .landscapeLeft + app.rotateToLandscapeAndWait() XCTAssertGreaterThan(app.frame.width, app.frame.height); XCTAssertEqual(name.value as? String, "Meera Maker") tap(app.buttons["Save changes"], in: app); XCTAssertTrue(app.staticTexts["Profile saved."].waitForExistence(timeout: 5)); capture("Native profile saved landscape") let back = app.navigationBars.buttons["Profile"]; XCTAssertTrue(back.waitForExistence(timeout: 5)); back.tap() @@ -282,7 +288,7 @@ final class MemberActionsFlowTests: XCTestCase { tap(app.buttons["Add to request"], in: app); tap(app.buttons["review-request"], in: app) tap(app.buttons["submit-request"], in: app) XCTAssertTrue(app.staticTexts["Request received"].waitForExistence(timeout: 5)); capture("Request receipt largest text dark portrait") - XCUIDevice.shared.orientation = .landscapeLeft; XCTAssertGreaterThan(app.frame.width, app.frame.height) + app.rotateToLandscapeAndWait(); XCTAssertGreaterThan(app.frame.width, app.frame.height) tap(app.buttons["Done"], in: app) let add = app.buttons["Add to request"] revealFully(add, in: app) diff --git a/SpaceWorksUITests/NativeTestLaunch.swift b/SpaceWorksUITests/NativeTestLaunch.swift index 10c40cf..4a5988b 100644 --- a/SpaceWorksUITests/NativeTestLaunch.swift +++ b/SpaceWorksUITests/NativeTestLaunch.swift @@ -3,6 +3,15 @@ import UIKit /// UI-test runner policy. It never enters the application or its Release target. extension XCUIApplication { + func rotateToLandscapeAndWait(timeout: TimeInterval = 10) { + XCUIDevice.shared.orientation = .landscapeLeft + let orientation = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in + self.frame.width > self.frame.height + }, object: nil) + XCTAssertEqual(XCTWaiter.wait(for: [orientation], timeout: timeout), .completed, + "Requested landscape orientation must actually be visible.") + } + func launchForDeviceProfile() { let profiles: Set = ["compact", "phone", "large", "tablet", "tablet-landscape", "large-tablet"] let profile = ProcessInfo.processInfo.environment["SPACEWORKS_TEST_PROFILE"] ?? "phone" diff --git a/SpaceWorksUITests/NativeUIActions.swift b/SpaceWorksUITests/NativeUIActions.swift index c6c8223..d71cf09 100644 --- a/SpaceWorksUITests/NativeUIActions.swift +++ b/SpaceWorksUITests/NativeUIActions.swift @@ -2,20 +2,20 @@ import XCTest /// Native bars can overlap the accessibility scroll frame. Keep gestures in visible content. enum NativeUIActions { - static func tap(_ element: XCUIElement, in app: XCUIApplication) { + static func tap(_ element: XCUIElement, in app: XCUIApplication, requireWholeFrame: Bool = false) { var reviewDown = false for attempt in 0..<24 { let scroll = containingScroll(element, app) if element.exists { let insideScroll = contains(element, scroll) - if element.isHittable && !insideScroll { element.tap(); return } + if element.isHittable && !insideScroll && !requireWholeFrame { element.tap(); return } let frame = element.frame let viewport = visibleContent(scroll, app) let visible = frame.intersection(viewport) print("Native action \(element.identifier.isEmpty ? element.label : element.identifier), attempt \(attempt), frame \(frame), viewport \(viewport), hittable \(element.isHittable)") // A SwiftUI Toggle can expose a label rectangle that differs from its // native UISwitch activation point. Let XCTest activate a visible switch. - if element.elementType == .switch, element.isHittable, + if !requireWholeFrame, element.elementType == .switch, element.isHittable, !visible.isNull, visible.width > 4, visible.height > 4 { element.tap(); return } // A fitting review button must reveal its entire label before activation. // Its midpoint can be hittable while the second line is below the screen. @@ -25,21 +25,22 @@ enum NativeUIActions { // Gesture margins keep drags away from chrome; they are not clipping. // Check the complete review control against the actual bar-adjusted // viewport, with a 2pt safety margin, rather than its 8pt drag inset. - let positioned = fullReviewButton ? reviewViewport.insetBy(dx: 2, dy: 2).contains(frame) - : viewport.insetBy(dx: 2, dy: 2).contains(CGPoint(x: frame.midX, y: frame.midY)) + let positioned = requireWholeFrame + ? visibleContent(scroll, app, gestureInset: 0).insetBy(dx: 2, dy: 2).contains(frame) + : (fullReviewButton ? reviewViewport.insetBy(dx: 2, dy: 2).contains(frame) + : viewport.insetBy(dx: 2, dy: 2).contains(CGPoint(x: frame.midX, y: frame.midY))) if element.isHittable, !visible.isNull, visible.width > 4, visible.height > 4, positioned { // Use the native activation point. A wide Toggle's label centre is not // its switch thumb; coordinates are only needed for clipped text fields. element.tap(); return } - if (element.elementType == .textField || element.elementType == .secureTextField), + if !requireWholeFrame, (element.elementType == .textField || element.elementType == .secureTextField), element.isHittable, !visible.isNull, visible.width > 8, visible.height > 8 { element.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: visible.midX - frame.minX, dy: visible.midY - frame.minY)).tap(); return } - // Off-screen accessibility frames can jump across the viewport after - // rotation. Reversing on their midpoint oscillated around hidden controls. - // Sweep consistently in each direction; activation still requires a real - // visible, hittable target. New screens normally start near their top. + // Follow observed target geometry while it remains available. A fixed + // half-budget reversal can move a still-offscreen control away again. + // Missing or unusable targets retain the bounded two-direction sweep. if scroll.identifier == "request-review-scroll" { // Native geometry proves this target remains in the same modal. // Some drags can make no progress; do not reverse a resumed @@ -51,7 +52,14 @@ enum NativeUIActions { else if frame.maxY > viewport.maxY { reviewDown = false } } drag(scroll, app, target: element, down: reviewDown) - } else { drag(scroll, app, target: element, down: attempt >= 12) } + } else { + var down = attempt >= 12 + if frame.minY.isFinite, frame.maxY.isFinite, frame.height > 0 { + if frame.maxY <= viewport.minY || frame.minY < viewport.minY { down = true } + else if frame.minY >= viewport.maxY || frame.maxY > viewport.maxY { down = false } + } + drag(scroll, app, target: element, down: down) + } } else { drag(scroll, app, target: element, down: scroll.identifier == "request-review-scroll" ? reviewDown : attempt >= 12) } diff --git a/SpaceWorksUITests/NotificationsFlowTests.swift b/SpaceWorksUITests/NotificationsFlowTests.swift index 21f07ab..cea358f 100644 --- a/SpaceWorksUITests/NotificationsFlowTests.swift +++ b/SpaceWorksUITests/NotificationsFlowTests.swift @@ -5,11 +5,16 @@ final class NotificationsFlowTests: XCTestCase { private func tap(_ element: XCUIElement, _ app: XCUIApplication) { NativeUIActions.tap(element, in: app) } private func tab(_ app: XCUIApplication, _ name: String) { if app.tabBars.buttons[name].exists { tap(app.tabBars.buttons[name], app); return } - let item = app.descendants(matching: .any)["nav-\(name)"].firstMatch - let ready = NSPredicate(format: "exists == true AND isHittable == true") - if XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: ready, object: item)], timeout: 3) != .completed { - let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'sidebar'")).firstMatch - if sidebar.exists { sidebar.tap() } + let item = app.buttons["nav-\(name)"] + let readiness = NSPredicate(format: "exists == true AND isEnabled == true AND isHittable == true") + let ready = XCTNSPredicateExpectation(predicate: readiness, object: item) + if XCTWaiter.wait(for: [ready], timeout: 3) != .completed { + let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label ==[c] %@ OR label ==[c] %@", "Show Sidebar", "Toggle Sidebar")).firstMatch + let sidebarReady = XCTNSPredicateExpectation(predicate: readiness, object: sidebar) + XCTAssertEqual(XCTWaiter.wait(for: [sidebarReady], timeout: 3), .completed) + sidebar.tap() + let opened = XCTNSPredicateExpectation(predicate: readiness, object: item) + XCTAssertEqual(XCTWaiter.wait(for: [opened], timeout: 3), .completed) } tap(item, app) } @@ -49,9 +54,8 @@ final class NotificationsFlowTests: XCTestCase { tap(app.buttons["demo-push-other-space"], app) XCTAssertTrue(app.buttons["Mark all as read"].waitForExistence(timeout: 8)) XCTAssertTrue(app.buttons["Maker Space Bengaluru"].exists) - XCUIDevice.shared.orientation = .landscapeLeft - let landscape = NSPredicate { _, _ in app.frame.width > app.frame.height } - XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: landscape, object: app)], timeout: 5), .completed) + app.rotateToLandscapeAndWait(timeout: 5) + XCTAssertGreaterThan(app.frame.width, app.frame.height) capture("Notification switches authorized makerspace then opens Inbox landscape") } } diff --git a/SpaceWorksUITests/PaymentsFlowTests.swift b/SpaceWorksUITests/PaymentsFlowTests.swift index 123ba15..a53d22a 100644 --- a/SpaceWorksUITests/PaymentsFlowTests.swift +++ b/SpaceWorksUITests/PaymentsFlowTests.swift @@ -43,7 +43,10 @@ final class PaymentsFlowTests: XCTestCase { guard viewport.width > 32, viewport.height > 64 else { break } let down = frame.minY < viewport.minY let distance = min(200, viewport.height * 0.45) - let x = viewport.midX, y = down ? viewport.minY + 16 : viewport.maxY - 16 + // Begin in the real content padding, outside outcome buttons and away + // from the phone's bottom system gesture edge. + let x = min(viewport.maxX - 12, max(viewport.minX + 12, frame.minX - 12)) + let y = viewport.midY + (down ? -distance : distance) / 2 let start = app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: x - app.frame.minX, dy: y - app.frame.minY)) let end = app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: x - app.frame.minX, dy: y + (down ? distance : -distance) - app.frame.minY)) start.press(forDuration: 0.1, thenDragTo: end, withVelocity: XCUIGestureVelocity(250), thenHoldForDuration: 0.3) diff --git a/SpaceWorksUITests/SecurityFlowTests.swift b/SpaceWorksUITests/SecurityFlowTests.swift index af1166f..2493675 100644 --- a/SpaceWorksUITests/SecurityFlowTests.swift +++ b/SpaceWorksUITests/SecurityFlowTests.swift @@ -14,6 +14,17 @@ final class SecurityFlowTests: XCTestCase { NativeUIActions.tap(app.buttons["Send another code"], in: app) XCTAssertTrue(app.staticTexts["Demo code ready. Use 123456. No email was sent."].waitForExistence(timeout: 5)) let code = app.textFields["account-code"]; NativeUIActions.tap(code, in: app); code.typeText("123456") + XCTAssertEqual(code.value as? String, "123456") + let keyboardDone = app.buttons["account-code-keyboard-done"] + XCTAssertTrue(keyboardDone.waitForExistence(timeout: 5)) + XCTAssertTrue(keyboardDone.isHittable) + XCTAssertGreaterThan(keyboardDone.frame.width, 0) + XCTAssertGreaterThan(keyboardDone.frame.height, 0) + XCTAssertTrue(app.frame.insetBy(dx: 2, dy: 2).contains(keyboardDone.frame)) + let keyboardAction = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + keyboardAction.name = "Email verification keyboard action"; keyboardAction.lifetime = .keepAlways; add(keyboardAction) + keyboardDone.tap() + XCTAssertTrue(app.keyboards.firstMatch.waitForNonExistence(timeout: 5)) NativeUIActions.tap(app.buttons["Confirm email"], in: app) XCTAssertTrue(app.staticTexts["Email confirmed."].waitForExistence(timeout: 5)) let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); attachment.name = "Isolated email verification preview"; attachment.lifetime = .keepAlways; add(attachment) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f737123..110240b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,10 @@ ## A8 Usage guide and release handoff — implementation checkpoint (2026-10-17) +The later `6505723` large-phone checkpoint passed all 294 native cases but failed 14 of 36 UI journeys. Actual logs and full-OS media show several distinct interaction problems: fixed half-budget scroll reversal moved known Account, guide and event targets away before they became reachable; a partially exposed recovery Toggle stayed unacknowledged; an MFA tap landed on the phone's bottom edge; and a demo-payment reveal gesture activated a different outcome. Test helpers now follow finite target geometry, fully reveal and verify recovery acknowledgment, focus the visible MFA field, and drag inside the payment sheet's padding. The native activation, gesture budgets and downstream outcomes remain intact. Notifications now queries the actual native sidebar button and never closes an already-open sidebar through a generic Hide Sidebar match. + +The landscape email-verification recording showed its numeric keyboard covering the action without a usable dismissal control. Account access now provides a focus-only native Done action above its scroll content; its existing journey verifies the filled code, the complete real Done frame and keyboard dismissal before the unchanged confirmation. No account operation or submission behavior changes. Post-launch rotation checks now await actual app geometry with the existing launch-policy bound (Notifications retains its five-second bound); the old Notifications rotation wait also timed out, so readiness alone is not claimed to resolve the orientation mechanism. A focused native diagnostic and the complete corrected matrix remain required. A7 at actual `3193b08` has separately passed its 285 native/34 UI and Release gate; this A8 checkpoint is not acceptance. Counts remain 294 native/36 UI/40 Python with 781 English keys. + The `6505723` checkpoint exposed three further concrete readiness/presentation issues. Phone booking reached the correct destination, but helper enumeration still held an outgoing discovery ScrollView; the journey now waits for that selected link to disappear and the actual booking title before reading destination geometry. A successful large-tablet screenshot showed the membership destructive label clipped inside a confirmation popover; that existing two-choice decision now uses a native alert with captured presenting data and strict whole-action alert bounds in its test. Normal-tablet Profile setup now waits for the actual enabled native button and only a verified Show/Toggle Sidebar action before continuing. These preserve every outcome and use no sleeps or retries. The tablet renewal recording showed a native center tap and pressed feedback without a result or error. Renewal now mirrors the existing request form: its action is stable when no cooldown exists, and a periodic view exists only for a real retry deadline. Busy/deadline disabling, the operation and result are unchanged. This removes unnecessary periodic redraw; a causal link to the lost press is not established. The existing native tap and Loan renewed assertion remain, with a pre-activation screenshot for direct evidence. Full corrected native/UI/Release and visual acceptance remain required. From 306983d02f3d92ce4e8a1895665bbf69dea38662 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:50:33 +0530 Subject: [PATCH 53/98] Use native sidebar controls in remaining UI journeys Match the verified Account and Notifications readiness pattern in existing Main, guide, member, payment and staff test setup. Query actual native navigation buttons and open only a ready Show Sidebar or Toggle Sidebar control, preserving tab-bar paths, direct native taps and all journey outcomes. Co-Authored-By: Codex --- SpaceWorksUITests/GuideFlowTests.swift | 14 ++++++++++---- SpaceWorksUITests/MainFlowTests.swift | 13 +++++++++---- SpaceWorksUITests/MemberFlowTests.swift | 14 ++++++++++---- SpaceWorksUITests/PaymentsFlowTests.swift | 14 ++++++++++---- SpaceWorksUITests/StaffFlowTests.swift | 14 ++++++++++---- docs/CHANGELOG.md | 2 ++ 6 files changed, 51 insertions(+), 20 deletions(-) diff --git a/SpaceWorksUITests/GuideFlowTests.swift b/SpaceWorksUITests/GuideFlowTests.swift index 5ef1bac..8b5d7a1 100644 --- a/SpaceWorksUITests/GuideFlowTests.swift +++ b/SpaceWorksUITests/GuideFlowTests.swift @@ -16,10 +16,16 @@ final class GuideFlowTests: XCTestCase { } private func tab(_ name: String, app: XCUIApplication) { if app.tabBars.buttons[name].exists { tap(app.tabBars.buttons[name], in: app); return } - let target = app.descendants(matching: .any)["nav-\(name)"].firstMatch - if !target.isHittable { - let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'sidebar'")).firstMatch - if sidebar.exists { sidebar.tap() } + let target = app.buttons["nav-\(name)"] + let readiness = NSPredicate(format: "exists == true AND isEnabled == true AND isHittable == true") + let ready = XCTNSPredicateExpectation(predicate: readiness, object: target) + if XCTWaiter.wait(for: [ready], timeout: 3) != .completed { + let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label ==[c] %@ OR label ==[c] %@", "Show Sidebar", "Toggle Sidebar")).firstMatch + let sidebarReady = XCTNSPredicateExpectation(predicate: readiness, object: sidebar) + XCTAssertEqual(XCTWaiter.wait(for: [sidebarReady], timeout: 3), .completed) + sidebar.tap() + let opened = XCTNSPredicateExpectation(predicate: readiness, object: target) + XCTAssertEqual(XCTWaiter.wait(for: [opened], timeout: 3), .completed) } tap(target, in: app) } diff --git a/SpaceWorksUITests/MainFlowTests.swift b/SpaceWorksUITests/MainFlowTests.swift index 5c63a35..c9aca19 100644 --- a/SpaceWorksUITests/MainFlowTests.swift +++ b/SpaceWorksUITests/MainFlowTests.swift @@ -27,11 +27,16 @@ final class MainFlowTests: XCTestCase { } private func tab(_ app: XCUIApplication, _ title: String) { if app.tabBars.buttons[title].exists { app.tabBars.buttons[title].tap(); return } - let item = app.descendants(matching: .any)["nav-\(title)"].firstMatch - let ready = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == true AND isHittable == true"), object: item) + let item = app.buttons["nav-\(title)"] + let readiness = NSPredicate(format: "exists == true AND isEnabled == true AND isHittable == true") + let ready = XCTNSPredicateExpectation(predicate: readiness, object: item) if XCTWaiter.wait(for: [ready], timeout: 3) != .completed { - let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'sidebar'")).firstMatch - if sidebar.exists { sidebar.tap() } + let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label ==[c] %@ OR label ==[c] %@", "Show Sidebar", "Toggle Sidebar")).firstMatch + let sidebarReady = XCTNSPredicateExpectation(predicate: readiness, object: sidebar) + XCTAssertEqual(XCTWaiter.wait(for: [sidebarReady], timeout: 3), .completed) + sidebar.tap() + let opened = XCTNSPredicateExpectation(predicate: readiness, object: item) + XCTAssertEqual(XCTWaiter.wait(for: [opened], timeout: 3), .completed) } XCTAssertTrue(item.waitForExistence(timeout: 5)); item.tap() } diff --git a/SpaceWorksUITests/MemberFlowTests.swift b/SpaceWorksUITests/MemberFlowTests.swift index 4d0a8ed..9b392c7 100644 --- a/SpaceWorksUITests/MemberFlowTests.swift +++ b/SpaceWorksUITests/MemberFlowTests.swift @@ -15,10 +15,16 @@ final class MemberFlowTests: XCTestCase { } private func tab(_ app: XCUIApplication, _ name: String) { if app.tabBars.buttons[name].exists { app.tabBars.buttons[name].tap(); return } - let item = app.descendants(matching: .any)["nav-\(name)"].firstMatch - if !item.isHittable { - let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'sidebar'")).firstMatch - if sidebar.exists { sidebar.tap() } + let item = app.buttons["nav-\(name)"] + let readiness = NSPredicate(format: "exists == true AND isEnabled == true AND isHittable == true") + let ready = XCTNSPredicateExpectation(predicate: readiness, object: item) + if XCTWaiter.wait(for: [ready], timeout: 3) != .completed { + let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label ==[c] %@ OR label ==[c] %@", "Show Sidebar", "Toggle Sidebar")).firstMatch + let sidebarReady = XCTNSPredicateExpectation(predicate: readiness, object: sidebar) + XCTAssertEqual(XCTWaiter.wait(for: [sidebarReady], timeout: 3), .completed) + sidebar.tap() + let opened = XCTNSPredicateExpectation(predicate: readiness, object: item) + XCTAssertEqual(XCTWaiter.wait(for: [opened], timeout: 3), .completed) } XCTAssertTrue(item.waitForExistence(timeout: 5)); item.tap() } diff --git a/SpaceWorksUITests/PaymentsFlowTests.swift b/SpaceWorksUITests/PaymentsFlowTests.swift index a53d22a..b1c9977 100644 --- a/SpaceWorksUITests/PaymentsFlowTests.swift +++ b/SpaceWorksUITests/PaymentsFlowTests.swift @@ -12,10 +12,16 @@ final class PaymentsFlowTests: XCTestCase { tap(app.buttons["demo-sign-in"], in: app); tap(app.buttons["demo-space-0"], in: app) if app.tabBars.buttons["Profile"].exists { tap(app.tabBars.buttons["Profile"], in: app) } else { - let profile = app.descendants(matching: .any)["nav-Profile"].firstMatch - if !profile.isHittable { - let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'sidebar'")).firstMatch - if sidebar.exists { sidebar.tap() } + let profile = app.buttons["nav-Profile"] + let readiness = NSPredicate(format: "exists == true AND isEnabled == true AND isHittable == true") + let ready = XCTNSPredicateExpectation(predicate: readiness, object: profile) + if XCTWaiter.wait(for: [ready], timeout: 3) != .completed { + let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label ==[c] %@ OR label ==[c] %@", "Show Sidebar", "Toggle Sidebar")).firstMatch + let sidebarReady = XCTNSPredicateExpectation(predicate: readiness, object: sidebar) + XCTAssertEqual(XCTWaiter.wait(for: [sidebarReady], timeout: 3), .completed) + sidebar.tap() + let opened = XCTNSPredicateExpectation(predicate: readiness, object: profile) + XCTAssertEqual(XCTWaiter.wait(for: [opened], timeout: 3), .completed) } tap(profile, in: app) } diff --git a/SpaceWorksUITests/StaffFlowTests.swift b/SpaceWorksUITests/StaffFlowTests.swift index 5ff4628..4db7b9d 100644 --- a/SpaceWorksUITests/StaffFlowTests.swift +++ b/SpaceWorksUITests/StaffFlowTests.swift @@ -14,10 +14,16 @@ final class StaffFlowTests: XCTestCase { tap(app.buttons["demo-sign-in"], in: app); tap(app.buttons["demo-space-0"], in: app) if app.tabBars.buttons["Profile"].exists { tap(app.tabBars.buttons["Profile"], in: app) } else { - let profile = app.descendants(matching: .any)["nav-Profile"].firstMatch - if !profile.isHittable { - let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'sidebar'")).firstMatch - if sidebar.exists { sidebar.tap() } + let profile = app.buttons["nav-Profile"] + let readiness = NSPredicate(format: "exists == true AND isEnabled == true AND isHittable == true") + let ready = XCTNSPredicateExpectation(predicate: readiness, object: profile) + if XCTWaiter.wait(for: [ready], timeout: 3) != .completed { + let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label ==[c] %@ OR label ==[c] %@", "Show Sidebar", "Toggle Sidebar")).firstMatch + let sidebarReady = XCTNSPredicateExpectation(predicate: readiness, object: sidebar) + XCTAssertEqual(XCTWaiter.wait(for: [sidebarReady], timeout: 3), .completed) + sidebar.tap() + let opened = XCTNSPredicateExpectation(predicate: readiness, object: profile) + XCTAssertEqual(XCTWaiter.wait(for: [opened], timeout: 3), .completed) } tap(profile, in: app) } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 110240b..7375373 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## A8 Usage guide and release handoff — implementation checkpoint (2026-10-17) +A bounded test-source followthrough replaces the same untyped navigation query and generic sidebar match in the remaining Main, guide, member, payment and staff setup helpers. Live and demo navigation attach these identifiers to actual native Buttons; each helper now waits for that enabled, hittable button and may open only a verified Show Sidebar or Toggle Sidebar action. Existing tab-bar paths, native final taps and all journey outcomes are retained. This follows the demonstrated Notifications helper failure and matching source structure; it does not claim every helper independently failed. No app behavior, cases or gesture budgets change; focused native and complete exact-head validation remain required. + The later `6505723` large-phone checkpoint passed all 294 native cases but failed 14 of 36 UI journeys. Actual logs and full-OS media show several distinct interaction problems: fixed half-budget scroll reversal moved known Account, guide and event targets away before they became reachable; a partially exposed recovery Toggle stayed unacknowledged; an MFA tap landed on the phone's bottom edge; and a demo-payment reveal gesture activated a different outcome. Test helpers now follow finite target geometry, fully reveal and verify recovery acknowledgment, focus the visible MFA field, and drag inside the payment sheet's padding. The native activation, gesture budgets and downstream outcomes remain intact. Notifications now queries the actual native sidebar button and never closes an already-open sidebar through a generic Hide Sidebar match. The landscape email-verification recording showed its numeric keyboard covering the action without a usable dismissal control. Account access now provides a focus-only native Done action above its scroll content; its existing journey verifies the filled code, the complete real Done frame and keyboard dismissal before the unchanged confirmation. No account operation or submission behavior changes. Post-launch rotation checks now await actual app geometry with the existing launch-policy bound (Notifications retains its five-second bound); the old Notifications rotation wait also timed out, so readiness alone is not claimed to resolve the orientation mechanism. A focused native diagnostic and the complete corrected matrix remain required. A7 at actual `3193b08` has separately passed its 285 native/34 UI and Release gate; this A8 checkpoint is not acceptance. Counts remain 294 native/36 UI/40 Python with 781 English keys. From 4e72fb2453f30cfea75a2c03ca9aa7548def4e7c Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:46:14 +0530 Subject: [PATCH 54/98] Use native scrolling to reveal demo payment outcomes Replace held coordinate drags with native swipes on the identified outcome sheet. Preserve the bounded reveal loop, complete intended-action checks, direct native activation and every payment outcome assertion. Co-Authored-By: Codex --- SpaceWorksUITests/PaymentsFlowTests.swift | 12 ++++-------- docs/CHANGELOG.md | 2 ++ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/SpaceWorksUITests/PaymentsFlowTests.swift b/SpaceWorksUITests/PaymentsFlowTests.swift index b1c9977..b92112a 100644 --- a/SpaceWorksUITests/PaymentsFlowTests.swift +++ b/SpaceWorksUITests/PaymentsFlowTests.swift @@ -48,14 +48,10 @@ final class PaymentsFlowTests: XCTestCase { if action.isHittable, frame.width > 0, frame.height > 0, viewport.contains(frame) { return } guard viewport.width > 32, viewport.height > 64 else { break } let down = frame.minY < viewport.minY - let distance = min(200, viewport.height * 0.45) - // Begin in the real content padding, outside outcome buttons and away - // from the phone's bottom system gesture edge. - let x = min(viewport.maxX - 12, max(viewport.minX + 12, frame.minX - 12)) - let y = viewport.midY + (down ? -distance : distance) / 2 - let start = app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: x - app.frame.minX, dy: y - app.frame.minY)) - let end = app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: x - app.frame.minX, dy: y + (down ? distance : -distance) - app.frame.minY)) - start.press(forDuration: 0.1, thenDragTo: end, withVelocity: XCUIGestureVelocity(250), thenHoldForDuration: 0.3) + // Use the identified sheet's native scroll gesture. A held coordinate + // drag activated a different outcome while revealing this action. + if down { scroll.swipeDown(velocity: .slow) } + else { scroll.swipeUp(velocity: .slow) } } attach("Demo outcome action not fully visible") XCTFail("The complete native outcome action must be reachable inside its own sheet scroll view.") diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7375373..e2760a2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## A8 Usage guide and release handoff — implementation checkpoint (2026-10-17) +The `547282b` focused large-phone diagnostic passed seven of eight selected UI cases, including both rotation cases, MFA, deletion, recovery, guide and event coverage. Its payment recording showed a second held reveal gesture pressing the confirmed choice and dismissing the sheet before the intended canceled action was reached; the final accessibility snapshot correctly showed that accidentally selected confirmed result. The payment helper now uses native swipes on the same identified sheet ScrollView instead of synthesized held coordinate drags. The 24-attempt direction loop, complete intended-action bounds, final direct native tap and all canceled/pending/retry/PDF assertions remain unchanged. This test-only correction needs the focused payment diagnostic before the complete exact-head matrix; it changes no app payment state or provider behavior. + A bounded test-source followthrough replaces the same untyped navigation query and generic sidebar match in the remaining Main, guide, member, payment and staff setup helpers. Live and demo navigation attach these identifiers to actual native Buttons; each helper now waits for that enabled, hittable button and may open only a verified Show Sidebar or Toggle Sidebar action. Existing tab-bar paths, native final taps and all journey outcomes are retained. This follows the demonstrated Notifications helper failure and matching source structure; it does not claim every helper independently failed. No app behavior, cases or gesture budgets change; focused native and complete exact-head validation remain required. The later `6505723` large-phone checkpoint passed all 294 native cases but failed 14 of 36 UI journeys. Actual logs and full-OS media show several distinct interaction problems: fixed half-budget scroll reversal moved known Account, guide and event targets away before they became reachable; a partially exposed recovery Toggle stayed unacknowledged; an MFA tap landed on the phone's bottom edge; and a demo-payment reveal gesture activated a different outcome. Test helpers now follow finite target geometry, fully reveal and verify recovery acknowledgment, focus the visible MFA field, and drag inside the payment sheet's padding. The native activation, gesture budgets and downstream outcomes remain intact. Notifications now queries the actual native sidebar button and never closes an already-open sidebar through a generic Hide Sidebar match. From 64f5e75a8c1924ca792c74aa8c53a95de5faaeb7 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:47:47 +0530 Subject: [PATCH 55/98] Wait for settled account navigation and valid scroll geometry Wait for the outgoing account entry to disappear and the actual destination before continuing UI actions. Reject transient invalid scroll-container geometry before querying hittability, preserving native activation, selector priorities and every test outcome. Co-Authored-By: Codex --- SpaceWorksUITests/AccountFlowTests.swift | 6 +++++- SpaceWorksUITests/MemberActionsFlowTests.swift | 10 +++++++++- SpaceWorksUITests/NativeUIActions.swift | 10 +++++++++- docs/CHANGELOG.md | 2 ++ 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/SpaceWorksUITests/AccountFlowTests.swift b/SpaceWorksUITests/AccountFlowTests.swift index a0cee93..77c853e 100644 --- a/SpaceWorksUITests/AccountFlowTests.swift +++ b/SpaceWorksUITests/AccountFlowTests.swift @@ -26,7 +26,11 @@ final class AccountFlowTests: XCTestCase { tap(profile, in: app) } } - tap(app.buttons["account-entry"], in: app); return app + let entry = app.buttons["account-entry"] + tap(entry, in: app) + XCTAssertTrue(entry.waitForNonExistence(timeout: 5)) + XCTAssertTrue(app.navigationBars["Account and security"].waitForExistence(timeout: 5)) + return app } private func confirmNativeDialog(_ title: String, action label: String, in app: XCUIApplication) { if title == "Leave this makerspace?" || title == "Cancel membership renewal?" { diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index 24e4ed8..93592d8 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -103,7 +103,15 @@ final class MemberActionsFlowTests: XCTestCase { return descendants.matching(NSPredicate(format: "label == %@", element.label)).firstMatch.exists } private func containingScroll(_ element: XCUIElement, _ app: XCUIApplication) -> XCUIElement { - let scrolls = app.scrollViews.allElementsBoundByIndex.filter { $0.exists && $0.isHittable } + let scrolls = app.scrollViews.allElementsBoundByIndex.filter { scroll in + guard scroll.exists else { return false } + let frame = scroll.frame + guard frame.minX.isFinite, frame.minY.isFinite, frame.width.isFinite, frame.height.isFinite, + frame.width > 0, frame.height > 0 else { return false } + let visible = frame.intersection(app.frame) + guard !visible.isNull, visible.width > 0, visible.height > 0 else { return false } + return scroll.isHittable + } // Presented SwiftUI sheets may appear in a backing view's AX descendants. // Its scroll cannot move the modal content. Prefer the actual review container. if let review = scrolls.first(where: { $0.identifier == "request-review-scroll" }) { return review } diff --git a/SpaceWorksUITests/NativeUIActions.swift b/SpaceWorksUITests/NativeUIActions.swift index d71cf09..b42fde2 100644 --- a/SpaceWorksUITests/NativeUIActions.swift +++ b/SpaceWorksUITests/NativeUIActions.swift @@ -74,7 +74,15 @@ enum NativeUIActions { return descendants.matching(NSPredicate(format: "label == %@", element.label)).firstMatch.exists } private static func containingScroll(_ element: XCUIElement, _ app: XCUIApplication) -> XCUIElement { - let scrolls = app.scrollViews.allElementsBoundByIndex.filter { $0.exists && $0.isHittable } + let scrolls = app.scrollViews.allElementsBoundByIndex.filter { scroll in + guard scroll.exists else { return false } + let frame = scroll.frame + guard frame.minX.isFinite, frame.minY.isFinite, frame.width.isFinite, frame.height.isFinite, + frame.width > 0, frame.height > 0 else { return false } + let visible = frame.intersection(app.frame) + guard !visible.isNull, visible.width > 0, visible.height > 0 else { return false } + return scroll.isHittable + } // Presented SwiftUI sheets may appear in a backing view's AX descendants. // Its scroll cannot move the modal content. Prefer the actual review container. if let review = scrolls.first(where: { $0.identifier == "request-review-scroll" }) { return review } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e2760a2..9d95bbd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## A8 Usage guide and release handoff — implementation checkpoint (2026-10-17) +The normal `4e72fb2` tablet gate passed all 294 native tests and 35 of 36 UI journeys. Its phone-account journey tapped Account and security, then queried a transient zero-frame ScrollView for hittability while navigation was still settling; XCTest rejected that container's activation point. The actual recording and final accessibility snapshot show the correct Account destination and phone entry. Account setup now waits for the retained outgoing entry to disappear and the actual destination navigation bar before continuing. Both identical scroll-container selectors reject nonfinite, zero-size or offscreen geometry before asking for hittability. Selector priority, fallback behavior, the 24-attempt budget and every native activation/outcome assertion are preserved. This is transition-safe test readiness, not an app-account defect claim or proof that separate accessibility snapshots are atomic. Complete corrected exact-head native/UI/Release and visual gates remain required. + The `547282b` focused large-phone diagnostic passed seven of eight selected UI cases, including both rotation cases, MFA, deletion, recovery, guide and event coverage. Its payment recording showed a second held reveal gesture pressing the confirmed choice and dismissing the sheet before the intended canceled action was reached; the final accessibility snapshot correctly showed that accidentally selected confirmed result. The payment helper now uses native swipes on the same identified sheet ScrollView instead of synthesized held coordinate drags. The 24-attempt direction loop, complete intended-action bounds, final direct native tap and all canceled/pending/retry/PDF assertions remain unchanged. This test-only correction needs the focused payment diagnostic before the complete exact-head matrix; it changes no app payment state or provider behavior. A bounded test-source followthrough replaces the same untyped navigation query and generic sidebar match in the remaining Main, guide, member, payment and staff setup helpers. Live and demo navigation attach these identifiers to actual native Buttons; each helper now waits for that enabled, hittable button and may open only a verified Show Sidebar or Toggle Sidebar action. Existing tab-bar paths, native final taps and all journey outcomes are retained. This follows the demonstrated Notifications helper failure and matching source structure; it does not claim every helper independently failed. No app behavior, cases or gesture budgets change; focused native and complete exact-head validation remain required. From cb037f54f86da163e3d0582090fbb7e42f274e0f Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:57:35 +0530 Subject: [PATCH 56/98] Keep membership confirmation actions readable at large text Co-Authored-By: Codex --- SpaceWorks/AccountViews.swift | 2 +- SpaceWorks/Resources/Localizable.xcstrings | 22 +++++++++++ SpaceWorksUITests/AccountFlowTests.swift | 44 +++++++++++++++++----- docs/CHANGELOG.md | 2 + 4 files changed, 59 insertions(+), 11 deletions(-) diff --git a/SpaceWorks/AccountViews.swift b/SpaceWorks/AccountViews.swift index 2bba40e..4b87a07 100644 --- a/SpaceWorks/AccountViews.swift +++ b/SpaceWorks/AccountViews.swift @@ -312,7 +312,7 @@ private struct AccountMembershipView: View { } }.task { await state.loadMembership() } .alert(decision == "leave" ? "Leave this makerspace?" : "Cancel membership renewal?", isPresented: Binding(get: { decision != nil }, set: { if !$0 { decision = nil } }), presenting: decision) { selectedDecision in - Button(selectedDecision == "leave" ? "Confirm leave" : "Confirm cancellation", role: .destructive) { let leave = selectedDecision == "leave"; decision = nil; Task { if leave { await state.leaveMakerspace() } else { await state.cancelRenewal() } } }; Button("Keep membership", role: .cancel) { decision = nil } + Button(selectedDecision == "leave" ? String(localized: "Leave") : String(localized: "Stop renewal"), role: .destructive) { let leave = selectedDecision == "leave"; decision = nil; Task { if leave { await state.leaveMakerspace() } else { await state.cancelRenewal() } } }; Button("Cancel", role: .cancel) { decision = nil } } } } diff --git a/SpaceWorks/Resources/Localizable.xcstrings b/SpaceWorks/Resources/Localizable.xcstrings index 7421d5b..e78539c 100644 --- a/SpaceWorks/Resources/Localizable.xcstrings +++ b/SpaceWorks/Resources/Localizable.xcstrings @@ -3587,6 +3587,17 @@ } } }, + "Leave": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Leave" + } + } + } + }, "Leave makerspace": { "extractionState": "manual", "localizations": { @@ -6733,6 +6744,17 @@ } } }, + "Stop renewal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop renewal" + } + } + } + }, "Superadmin accounts cannot sign in on a phone or tablet.": { "extractionState": "manual", "localizations": { diff --git a/SpaceWorksUITests/AccountFlowTests.swift b/SpaceWorksUITests/AccountFlowTests.swift index 77c853e..4ad0bdb 100644 --- a/SpaceWorksUITests/AccountFlowTests.swift +++ b/SpaceWorksUITests/AccountFlowTests.swift @@ -37,16 +37,20 @@ final class AccountFlowTests: XCTestCase { let alert = app.alerts.firstMatch XCTAssertTrue(alert.waitForExistence(timeout: 5)) XCTAssertTrue(alert.staticTexts[title].waitForExistence(timeout: 5)) - let action = alert.buttons[label] - XCTAssertTrue(action.waitForExistence(timeout: 5)) - XCTAssertTrue(action.isHittable) - let frame = action.frame - XCTAssertGreaterThan(frame.width, 0) - XCTAssertGreaterThan(frame.height, 0) - XCTAssertTrue(alert.frame.insetBy(dx: 2, dy: 2).contains(frame)) - XCTAssertTrue(app.frame.insetBy(dx: 2, dy: 2).contains(frame)) + let primaryLabel = title == "Leave this makerspace?" ? "Leave" : "Stop renewal" + for control in [alert.buttons[primaryLabel], alert.buttons["Cancel"]] { + XCTAssertTrue(control.waitForExistence(timeout: 5)) + XCTAssertTrue(control.isEnabled) + XCTAssertTrue(control.isHittable) + let frame = control.frame + XCTAssertGreaterThan(frame.width, 0) + XCTAssertGreaterThan(frame.height, 0) + XCTAssertTrue(alert.frame.insetBy(dx: 2, dy: 2).contains(frame)) + XCTAssertTrue(app.frame.insetBy(dx: 2, dy: 2).contains(frame)) + } attach("Native account confirmation") - action.tap() + alert.buttons[label].tap() + XCTAssertTrue(alert.waitForNonExistence(timeout: 5)) return } // Native confirmation popovers own separate title/action scroll regions. @@ -213,7 +217,27 @@ final class AccountFlowTests: XCTestCase { tap(reason, in: app); reason.typeText("Moving away") dismissAccountKeyboard("account-membership-keyboard-done", in: app) tap(app.buttons["account-leave-makerspace"], in: app) - confirmNativeDialog("Leave this makerspace?", action: "Confirm leave", in: app) + confirmNativeDialog("Leave this makerspace?", action: "Cancel", in: app) + XCTAssertTrue(app.navigationBars["Your membership"].exists) + XCTAssertTrue(app.staticTexts["Active"].exists) + XCTAssertEqual(reason.value as? String, "Moving away") + let renewal = app.buttons["Cancel membership renewal"] + tap(renewal, in: app) + confirmNativeDialog("Cancel membership renewal?", action: "Cancel", in: app) + XCTAssertTrue(app.staticTexts["Active"].exists) + XCTAssertEqual(reason.value as? String, "Moving away") + XCTAssertTrue(renewal.exists) + tap(renewal, in: app) + confirmNativeDialog("Cancel membership renewal?", action: "Stop renewal", in: app) + XCTAssertTrue(app.staticTexts["Membership renewal cancelled."].waitForExistence(timeout: 10)) + XCTAssertTrue(app.navigationBars["Your membership"].exists) + XCTAssertTrue(app.staticTexts["Active"].exists) + XCTAssertTrue(renewal.waitForNonExistence(timeout: 5)) + let finalReason = app.textViews["account-membership-reason"].exists ? app.textViews["account-membership-reason"] : app.textFields["account-membership-reason"] + tap(finalReason, in: app); finalReason.typeText("Moving away") + dismissAccountKeyboard("account-membership-keyboard-done", in: app) + tap(app.buttons["account-leave-makerspace"], in: app) + confirmNativeDialog("Leave this makerspace?", action: "Leave", in: app) XCTAssertTrue(app.buttons["demo-space-1"].waitForExistence(timeout: 10)); XCTAssertFalse(app.buttons["demo-space-0"].exists) attach("Confirmed membership leave keeps other makerspace") } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9d95bbd..d8794ef 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## A8 Usage guide and release handoff — implementation checkpoint (2026-10-17) +The current `64f5e75` large-tablet profile passed all native and UI cases, but its actual largest-text membership alert screenshot showed the cancel-role Keep membership label internally clipped to Keep/member-. Complete action frames did not prove complete native label rendering. The two existing decisions now use the concise native actions Leave or Stop renewal and Cancel, preserving captured decision, destructive/cancel roles and server mutation logic. The existing membership journey now exercises cancellation of both decisions, verifies the retained reason and active membership, confirms renewal cancellation while membership remains active, and finally confirms leaving with the unchanged remaining-membership outcome. Both native controls receive complete positive alert/app bounds and hittability checks before every dialog capture. This is a real visual correction after passing tests; complete corrected-source native and full-OS visual validation remain required. + The normal `4e72fb2` tablet gate passed all 294 native tests and 35 of 36 UI journeys. Its phone-account journey tapped Account and security, then queried a transient zero-frame ScrollView for hittability while navigation was still settling; XCTest rejected that container's activation point. The actual recording and final accessibility snapshot show the correct Account destination and phone entry. Account setup now waits for the retained outgoing entry to disappear and the actual destination navigation bar before continuing. Both identical scroll-container selectors reject nonfinite, zero-size or offscreen geometry before asking for hittability. Selector priority, fallback behavior, the 24-attempt budget and every native activation/outcome assertion are preserved. This is transition-safe test readiness, not an app-account defect claim or proof that separate accessibility snapshots are atomic. Complete corrected exact-head native/UI/Release and visual gates remain required. The `547282b` focused large-phone diagnostic passed seven of eight selected UI cases, including both rotation cases, MFA, deletion, recovery, guide and event coverage. Its payment recording showed a second held reveal gesture pressing the confirmed choice and dismissing the sheet before the intended canceled action was reached; the final accessibility snapshot correctly showed that accidentally selected confirmed result. The payment helper now uses native swipes on the same identified sheet ScrollView instead of synthesized held coordinate drags. The 24-attempt direction loop, complete intended-action bounds, final direct native tap and all canceled/pending/retry/PDF assertions remain unchanged. This test-only correction needs the focused payment diagnostic before the complete exact-head matrix; it changes no app payment state or provider behavior. From e936a47cd15c130818afbefeab468a0793c255dc Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:15:42 +0530 Subject: [PATCH 57/98] Preserve the explicit single-scene app manifest Co-Authored-By: Codex --- docs/CHANGELOG.md | 2 ++ project.yml | 2 +- scripts/audit_bundle.py | 2 +- scripts/tests/test_audit_bundle.py | 5 ++++- scripts/tests/test_generation.py | 6 ++++++ 5 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d8794ef..fab963b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## A8 Usage guide and release handoff — implementation checkpoint (2026-10-17) +The old `64f5e75` phone profile completed all 294 native tests, 36 UI journeys and the unsigned physical-device Release build, then failed the strict packaged-resource audit because its scene manifest did not explicitly disable multiple scenes. The generator already writes that false flag for both configurations, but the project enabled Xcode automatic scene-manifest generation; Apple documents that setting as producing a multi-window manifest. The project now disables that override so the explicit generated manifest remains authoritative. Existing generation checks verify both false flags and the disabled override; the unchanged strict bundle audit also records the actual packaged scene manifest for diagnosis. Corrected-source Debug/Release build and actual-bundle audit proof remain required. + The current `64f5e75` large-tablet profile passed all native and UI cases, but its actual largest-text membership alert screenshot showed the cancel-role Keep membership label internally clipped to Keep/member-. Complete action frames did not prove complete native label rendering. The two existing decisions now use the concise native actions Leave or Stop renewal and Cancel, preserving captured decision, destructive/cancel roles and server mutation logic. The existing membership journey now exercises cancellation of both decisions, verifies the retained reason and active membership, confirms renewal cancellation while membership remains active, and finally confirms leaving with the unchanged remaining-membership outcome. Both native controls receive complete positive alert/app bounds and hittability checks before every dialog capture. This is a real visual correction after passing tests; complete corrected-source native and full-OS visual validation remain required. The normal `4e72fb2` tablet gate passed all 294 native tests and 35 of 36 UI journeys. Its phone-account journey tapped Account and security, then queried a transient zero-frame ScrollView for hittability while navigation was still settling; XCTest rejected that container's activation point. The actual recording and final accessibility snapshot show the correct Account destination and phone entry. Account setup now waits for the retained outgoing entry to disappear and the actual destination navigation bar before continuing. Both identical scroll-container selectors reject nonfinite, zero-size or offscreen geometry before asking for hittability. Selector priority, fallback behavior, the 24-attempt budget and every native activation/outcome assertion are preserved. This is transition-safe test readiness, not an app-account defect claim or proof that separate accessibility snapshots are atomic. Complete corrected exact-head native/UI/Release and visual gates remain required. diff --git a/project.yml b/project.yml index 8eebf6a..697fd1d 100644 --- a/project.yml +++ b/project.yml @@ -60,7 +60,7 @@ targets: DEVELOPMENT_TEAM: $(BRAND_TEAM_ID) INFOPLIST_KEY_CFBundleDisplayName: $(BRAND_DISPLAY_NAME) INFOPLIST_KEY_UILaunchScreen_Generation: YES - INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES + INFOPLIST_KEY_UIApplicationSceneManifest_Generation: NO ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon 'CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]': SpaceWorks/Generated/Simulator.entitlements preBuildScripts: diff --git a/scripts/audit_bundle.py b/scripts/audit_bundle.py index 3c24cc1..f8b57bf 100644 --- a/scripts/audit_bundle.py +++ b/scripts/audit_bundle.py @@ -50,7 +50,7 @@ def plist(path): api_resources = [path for path in app.rglob('*.bundle') if 'SpaceWorksAPI' in path.name and (path / 'en.lproj/Localizable.strings').is_file()] check(bool(api_resources), 'Generated API package English resource bundle is missing') manifests = sorted(str(path.relative_to(app)) for path in app.rglob('PrivacyInfo.xcprivacy')) - result = {'kind': 'unsigned-release-resource-audit', 'bundle_id': info.get('CFBundleIdentifier'), 'version': info.get('CFBundleShortVersionString'), 'build': info.get('CFBundleVersion'), 'device_families': info.get('UIDeviceFamily'), 'privacy_manifests_present': manifests, 'api_resource_bundles': sorted(str(path.relative_to(app)) for path in api_resources), 'failures': failures} + result = {'kind': 'unsigned-release-resource-audit', 'bundle_id': info.get('CFBundleIdentifier'), 'version': info.get('CFBundleShortVersionString'), 'build': info.get('CFBundleVersion'), 'device_families': info.get('UIDeviceFamily'), 'scene_manifest': info.get('UIApplicationSceneManifest'), 'privacy_manifests_present': manifests, 'api_resource_bundles': sorted(str(path.relative_to(app)) for path in api_resources), 'failures': failures} return result diff --git a/scripts/tests/test_audit_bundle.py b/scripts/tests/test_audit_bundle.py index ca95352..099d953 100644 --- a/scripts/tests/test_audit_bundle.py +++ b/scripts/tests/test_audit_bundle.py @@ -34,6 +34,7 @@ def test_accepts_packaged_resources_and_records_unsigned_scope(self): result = module.audit(self.app, self.brand) self.assertEqual(result['failures'], []) self.assertEqual(result['kind'], 'unsigned-release-resource-audit') + self.assertEqual(result['scene_manifest'], {'UIApplicationSupportsMultipleScenes': False}) self.assertEqual(result['privacy_manifests_present'], ['PrivacyInfo.xcprivacy']) def test_catches_source_resources_omitted_from_app(self): @@ -64,7 +65,9 @@ def test_requires_explicit_single_scene_credential_contract(self): for manifest in [{}, {'UIApplicationSupportsMultipleScenes': True}]: self.info['UIApplicationSceneManifest'] = manifest self.write_info() - self.assertTrue(any('Single-scene' in failure for failure in module.audit(self.app, self.brand)['failures'])) + result = module.audit(self.app, self.brand) + self.assertEqual(result['scene_manifest'], manifest) + self.assertTrue(any('Single-scene' in failure for failure in result['failures'])) if __name__ == '__main__': unittest.main() diff --git a/scripts/tests/test_generation.py b/scripts/tests/test_generation.py index d848fb7..86af1d8 100644 --- a/scripts/tests/test_generation.py +++ b/scripts/tests/test_generation.py @@ -149,6 +149,12 @@ def test_release_has_no_transport_security_exceptions(self): release = plistlib.loads((ROOT / "SpaceWorks/Generated/Info-Release.plist").read_bytes()) self.assertEqual(debug["NSAppTransportSecurity"], {"NSAllowsLocalNetworking": True}) self.assertNotIn("NSAppTransportSecurity", release) + # Xcode must preserve the explicit generated single-scene manifest. + for info in [debug, release]: + self.assertIs(info["UIApplicationSceneManifest"]["UIApplicationSupportsMultipleScenes"], False) + project = (ROOT / "project.yml").read_text() + self.assertIn(" INFOPLIST_KEY_UIApplicationSceneManifest_Generation: NO\n", project) + self.assertNotIn("INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES", project) def test_simulator_keychain_identity_never_changes_physical_entitlements(self): simulator = plistlib.loads((ROOT / "SpaceWorks/Generated/Simulator.entitlements").read_bytes()) From 3da09b868b11803c183b6c7152b4cc16f5ca115f Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:52:51 +0530 Subject: [PATCH 58/98] Keep large-text sign-up actions readable and captures scoped Co-Authored-By: Codex --- SpaceWorks/AuthView.swift | 13 +++++++++-- SpaceWorksUITests/MainFlowTests.swift | 32 ++++++++++++++++++++++++--- docs/CHANGELOG.md | 2 ++ 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/SpaceWorks/AuthView.swift b/SpaceWorks/AuthView.swift index 6d23118..d5fbee7 100644 --- a/SpaceWorks/AuthView.swift +++ b/SpaceWorks/AuthView.swift @@ -150,7 +150,16 @@ struct SignInView: View { .accessibilityIdentifier("sign-in") } if auth.signupEnabled { - HStack { Text("New here?").foregroundStyle(Color.swMuted); Button("Create account") { accountForm = auth.accountForm(.signUp) }.frame(minHeight: 48) } + if typeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 8) { + Text("New here?").foregroundStyle(Color.swMuted) + Button("Create account") { accountForm = auth.accountForm(.signUp) } + .frame(maxWidth: .infinity, minHeight: 48, alignment: .leading) + .accessibilityIdentifier("create-account") + } + } else { + HStack { Text("New here?").foregroundStyle(Color.swMuted); Button("Create account") { accountForm = auth.accountForm(.signUp) }.frame(minHeight: 48).accessibilityIdentifier("create-account") } + } } } }.disabled(auth.busy) @@ -166,7 +175,7 @@ struct SignInView: View { .accessibilityHidden(true) } }.padding(24).frame(maxWidth: .infinity, minHeight: geometry.size.height) - }.background(Color.swBg).scrollDismissesKeyboard(.interactively) + }.background(Color.swBg).scrollDismissesKeyboard(.interactively).accessibilityIdentifier("sign-in-scroll") } .sheet(item: $accountForm) { form in AccountAccessView(form: form) { auth.username = form.email; accountForm = nil } } } diff --git a/SpaceWorksUITests/MainFlowTests.swift b/SpaceWorksUITests/MainFlowTests.swift index c9aca19..8e4c34a 100644 --- a/SpaceWorksUITests/MainFlowTests.swift +++ b/SpaceWorksUITests/MainFlowTests.swift @@ -76,11 +76,37 @@ final class MainFlowTests: XCTestCase { func testDarkModeLargestTextPortraitAndLandscape() { let app = launch(extra: ["--ui-start-light", "--ui-start-portrait", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]) app.buttons["appearance"].tap(); connect(app) - app.swipeUp(); attach(app, "Sign in largest text dark portrait") + let scroll = app.scrollViews["sign-in-scroll"] + XCTAssertTrue(scroll.waitForExistence(timeout: 5)) + func revealActions() { + let signIn = app.buttons["sign-in"] + let create = app.buttons["create-account"] + XCTAssertTrue(signIn.waitForExistence(timeout: 5)) + XCTAssertTrue(create.waitForExistence(timeout: 5)) + for attempt in 0..<24 { + let viewport = scroll.frame.intersection(app.frame).insetBy(dx: 2, dy: 2) + let signInFrame = signIn.frame + let createFrame = create.frame + let frames = [viewport, signInFrame, createFrame] + XCTAssertTrue(frames.allSatisfy { [$0.minX, $0.minY, $0.width, $0.height].allSatisfy(\.isFinite) && $0.width > 0 && $0.height > 0 }) + print("Sign-in actions attempt=\(attempt) signIn=\(signInFrame) create=\(createFrame) viewport=\(viewport)") + if viewport.contains(signInFrame), viewport.contains(createFrame) { + XCTAssertTrue(signIn.isEnabled && signIn.isHittable) + XCTAssertTrue(create.isEnabled && create.isHittable) + XCTAssertEqual(app.state, .runningForeground) + return + } + XCTAssertTrue(scroll.isHittable) + if signInFrame.union(createFrame).minY < viewport.minY { scroll.swipeDown(velocity: .slow) } + else { scroll.swipeUp(velocity: .slow) } + } + XCTFail("Sign-in and Create account actions must both fit the visible sign-in scroll viewport") + } + revealActions(); attach(app, "Sign in largest text dark portrait") app.rotateToLandscapeAndWait() XCTAssertGreaterThan(app.frame.width, app.frame.height) - app.swipeDown(); XCTAssertTrue(app.textFields["username"].exists) - app.swipeUp(); XCTAssertTrue(app.buttons["sign-in"].exists) + scroll.swipeDown(velocity: .slow); XCTAssertTrue(app.textFields["username"].exists) + revealActions(); XCTAssertTrue(app.buttons["sign-in"].exists) attach(app, "Sign in largest text dark landscape") } func testExploreDemoIsIsolatedAndExitsToServerSetup() { diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index fab963b..ed54560 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## A8 Usage guide and release handoff — implementation checkpoint (2026-10-17) +The old `64f5e75` compact profile passed all 294 native tests and 36 UI journeys, but actual full-OS review found Create account split inside a word beside New here? at the largest accessibility text size. That row now stacks vertically at accessibility sizes with a full-width native Create account action; normal-size layout, full type scale and the existing sign-up handler remain unchanged. The named landscape sign-in capture showed an OS clock/blur overlay instead of the app, so it is not accepted as app visual proof. The existing dark/largest-text journey now reveals both complete native actions within the identified sign-in ScrollView using bounded native swipes, checks real app/control readiness before each capture, and preserves orientation, username and sign-in assertions. This removes whole-app gestures from that capture path without dismissing system UI or assuming its cause. Corrected-source native execution and actual full-OS review remain required. + The old `64f5e75` phone profile completed all 294 native tests, 36 UI journeys and the unsigned physical-device Release build, then failed the strict packaged-resource audit because its scene manifest did not explicitly disable multiple scenes. The generator already writes that false flag for both configurations, but the project enabled Xcode automatic scene-manifest generation; Apple documents that setting as producing a multi-window manifest. The project now disables that override so the explicit generated manifest remains authoritative. Existing generation checks verify both false flags and the disabled override; the unchanged strict bundle audit also records the actual packaged scene manifest for diagnosis. Corrected-source Debug/Release build and actual-bundle audit proof remain required. The current `64f5e75` large-tablet profile passed all native and UI cases, but its actual largest-text membership alert screenshot showed the cancel-role Keep membership label internally clipped to Keep/member-. Complete action frames did not prove complete native label rendering. The two existing decisions now use the concise native actions Leave or Stop renewal and Cancel, preserving captured decision, destructive/cancel roles and server mutation logic. The existing membership journey now exercises cancellation of both decisions, verifies the retained reason and active membership, confirms renewal cancellation while membership remains active, and finally confirms leaving with the unchanged remaining-membership outcome. Both native controls receive complete positive alert/app bounds and hittability checks before every dialog capture. This is a real visual correction after passing tests; complete corrected-source native and full-OS visual validation remain required. From 601a8fee964d4e739bb7c866876e0bf05a579487 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:00:45 +0530 Subject: [PATCH 59/98] Preserve navigation hosts across phone rotation and tablet resize Co-Authored-By: Codex --- SpaceWorks/DemoMode.swift | 8 ++++++-- SpaceWorks/RootView.swift | 8 ++++++-- docs/CHANGELOG.md | 2 ++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/SpaceWorks/DemoMode.swift b/SpaceWorks/DemoMode.swift index c9bbfdc..a589ed7 100644 --- a/SpaceWorks/DemoMode.swift +++ b/SpaceWorks/DemoMode.swift @@ -297,6 +297,7 @@ struct DemoShell: View { @State private var account: AccountState @State private var actions: DemoMemberActionsRepository @State private var columns = NavigationSplitViewVisibility.automatic + @State private var preferredCompactColumn = NavigationSplitViewColumn.detail init(session: DemoSession, linkRouter: MobileLinkRouter) { self.session = session; self.linkRouter = linkRouter _account = State(initialValue: session.accountState(makerspaceID: session.selectedSpace + 7)) @@ -314,11 +315,13 @@ struct DemoShell: View { var body: some View { GeometryReader { geometry in VStack(spacing: 0) { - if sizeClass == .regular { - NavigationSplitView(columnVisibility: $columns) { + // Keep the navigation host's identity stable when a phone rotates or a tablet window resizes. + if UIDevice.current.userInterfaceIdiom == .pad { + NavigationSplitView(columnVisibility: $columns, preferredCompactColumn: $preferredCompactColumn) { List(AppTab.allCases) { tab in Button(LocalizedStringKey(tab.rawValue)) { navigation.selected = tab + preferredCompactColumn = .detail columns = geometry.size.width >= 1000 ? .all : .detailOnly }.frame(minHeight: 48).accessibilityIdentifier("nav-\(tab.rawValue)") .listRowBackground(navigation.selected == tab ? Color.swSidebarActive : Color.swSidebar) @@ -335,6 +338,7 @@ struct DemoShell: View { } } }.onChange(of: navigation.selected) { _, _ in + preferredCompactColumn = .detail columns = geometry.size.width >= 1000 ? .all : .detailOnly }.onChange(of: geometry.size.width, initial: true) { _, width in columns = width >= 1000 ? .all : .detailOnly diff --git a/SpaceWorks/RootView.swift b/SpaceWorks/RootView.swift index 49766ec..5a1e3e3 100644 --- a/SpaceWorks/RootView.swift +++ b/SpaceWorks/RootView.swift @@ -178,6 +178,7 @@ private struct MemberWorkspace: View { @State private var account: AccountState? let actions: LiveMemberActionsRepository @State private var columns = NavigationSplitViewVisibility.automatic + @State private var preferredCompactColumn = NavigationSplitViewColumn.detail init(state: AppState, auth: AuthState, origin: ServerOrigin, id: Int, slug: String, linkRouter: MobileLinkRouter) { self.state = state; self.auth = auth; self.linkRouter = linkRouter; self.makerspaceID = id let failureHandlers = auth.repositoryFailureHandlers() @@ -197,11 +198,13 @@ private struct MemberWorkspace: View { var body: some View { GeometryReader { geometry in VStack(spacing: 0) { - if sizeClass == .regular { - NavigationSplitView(columnVisibility: $columns) { + // Keep the navigation host's identity stable when a phone rotates or a tablet window resizes. + if UIDevice.current.userInterfaceIdiom == .pad { + NavigationSplitView(columnVisibility: $columns, preferredCompactColumn: $preferredCompactColumn) { List(AppTab.allCases) { tab in Button { navigation.selected = tab + preferredCompactColumn = .detail columns = geometry.size.width >= 1000 ? .all : .detailOnly } label: { Text(LocalizedStringKey(tab.rawValue)).frame(maxWidth: .infinity, minHeight: 48, alignment: .leading) @@ -222,6 +225,7 @@ private struct MemberWorkspace: View { } } }.onChange(of: navigation.selected) { _, _ in + preferredCompactColumn = .detail columns = geometry.size.width >= 1000 ? .all : .detailOnly }.onChange(of: geometry.size.width, initial: true) { _, width in columns = width >= 1000 ? .all : .detailOnly diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ed54560..59741ec 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## A8 Usage guide and release handoff — implementation checkpoint (2026-10-17) +The old `64f5e75` large-phone log showed an opened Data export destination disappear after rotation, leaving the backing Account screen; another request rotation lost its local review presentation. Source review found both live and demo shells switched their entire TabView/NavigationSplitView branch on horizontal size class. Typed route models survived, while inline child navigation and sheet state belonged to the replaced branch. Both shells now keep a device-stable host: phones retain TabView and tablets retain native NavigationSplitView, with an explicit preferred detail column for collapsed windows and tab changes. Existing typed paths, account/makerspace cleanup, capabilities, width-based sidebar visibility and compact inventory selection conversion are unchanged. This removes the demonstrated structural state-lifetime risk; existing export/request rotation outcomes remain strict, and focused native execution plus resized-tablet/phone full-OS review are still required. + The old `64f5e75` compact profile passed all 294 native tests and 36 UI journeys, but actual full-OS review found Create account split inside a word beside New here? at the largest accessibility text size. That row now stacks vertically at accessibility sizes with a full-width native Create account action; normal-size layout, full type scale and the existing sign-up handler remain unchanged. The named landscape sign-in capture showed an OS clock/blur overlay instead of the app, so it is not accepted as app visual proof. The existing dark/largest-text journey now reveals both complete native actions within the identified sign-in ScrollView using bounded native swipes, checks real app/control readiness before each capture, and preserves orientation, username and sign-in assertions. This removes whole-app gestures from that capture path without dismissing system UI or assuming its cause. Corrected-source native execution and actual full-OS review remain required. The old `64f5e75` phone profile completed all 294 native tests, 36 UI journeys and the unsigned physical-device Release build, then failed the strict packaged-resource audit because its scene manifest did not explicitly disable multiple scenes. The generator already writes that false flag for both configurations, but the project enabled Xcode automatic scene-manifest generation; Apple documents that setting as producing a multi-window manifest. The project now disables that override so the explicit generated manifest remains authoritative. Existing generation checks verify both false flags and the disabled override; the unchanged strict bundle audit also records the actual packaged scene manifest for diagnosis. Corrected-source Debug/Release build and actual-bundle audit proof remain required. From 5205d18d95141ce7f11a53266bd153cf19d6aafe Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:31:00 +0530 Subject: [PATCH 60/98] Require auth captures below the actual fixed header Co-Authored-By: Codex --- SpaceWorksUITests/MainFlowTests.swift | 39 ++++++++++++++++++++++++--- docs/CHANGELOG.md | 2 ++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/SpaceWorksUITests/MainFlowTests.swift b/SpaceWorksUITests/MainFlowTests.swift index 8e4c34a..36825c1 100644 --- a/SpaceWorksUITests/MainFlowTests.swift +++ b/SpaceWorksUITests/MainFlowTests.swift @@ -84,11 +84,21 @@ final class MainFlowTests: XCTestCase { XCTAssertTrue(signIn.waitForExistence(timeout: 5)) XCTAssertTrue(create.waitForExistence(timeout: 5)) for attempt in 0..<24 { - let viewport = scroll.frame.intersection(app.frame).insetBy(dx: 2, dy: 2) + let applicationFrame = app.frame + let scrollFrame = scroll.frame.intersection(applicationFrame) + let appearance = app.buttons["appearance"] + XCTAssertTrue(appearance.exists) + let headerFrame = appearance.frame let signInFrame = signIn.frame let createFrame = create.frame - let frames = [viewport, signInFrame, createFrame] + let frames = [applicationFrame, scrollFrame, headerFrame, signInFrame, createFrame] XCTAssertTrue(frames.allSatisfy { [$0.minX, $0.minY, $0.width, $0.height].allSatisfy(\.isFinite) && $0.width > 0 && $0.height > 0 }) + XCTAssertTrue(appearance.isHittable) + let visibleTop = max(scrollFrame.minY, headerFrame.maxY) + let viewport = CGRect(x: scrollFrame.minX + 2, y: visibleTop + 2, + width: scrollFrame.width - 4, height: scrollFrame.maxY - visibleTop - 4) + XCTAssertGreaterThan(viewport.width, 0) + XCTAssertGreaterThan(viewport.height, 0) print("Sign-in actions attempt=\(attempt) signIn=\(signInFrame) create=\(createFrame) viewport=\(viewport)") if viewport.contains(signInFrame), viewport.contains(createFrame) { XCTAssertTrue(signIn.isEnabled && signIn.isHittable) @@ -97,7 +107,30 @@ final class MainFlowTests: XCTestCase { return } XCTAssertTrue(scroll.isHittable) - if signInFrame.union(createFrame).minY < viewport.minY { scroll.swipeDown(velocity: .slow) } + let actionsFrame = signInFrame.union(createFrame) + let translation = actionsFrame.minY < viewport.minY + ? viewport.minY - actionsFrame.minY + 2 : viewport.maxY - actionsFrame.maxY - 2 + let adjustmentLimit = min(120, viewport.height * 0.4) + if abs(translation) <= adjustmentLimit { + let movement = (translation < 0 ? -1.0 : 1.0) * min(max(abs(translation), 12), adjustmentLimit) + let x = viewport.minX + 12 + let startY = viewport.midY - movement / 2 + let endY = viewport.midY + movement / 2 + let dragFrame = CGRect(x: x - 2, y: min(startY, endY) - 2, width: 4, height: abs(movement) + 4) + XCTAssertTrue(viewport.contains(dragFrame) && scrollFrame.contains(dragFrame)) + let controls = app.buttons.allElementsBoundByIndex + app.textFields.allElementsBoundByIndex + + app.secureTextFields.allElementsBoundByIndex + app.textViews.allElementsBoundByIndex + for control in controls where control.exists { + let frame = control.frame + if [frame.minX, frame.minY, frame.width, frame.height].allSatisfy(\.isFinite), frame.width > 0, frame.height > 0 { + XCTAssertFalse(frame.intersects(dragFrame), "The reveal gutter must not intersect an interactive control") + } + } + print("Sign-in short reveal path=\(dragFrame) visibleViewport=\(viewport)") + let origin = app.coordinate(withNormalizedOffset: .zero) + origin.withOffset(CGVector(dx: x - applicationFrame.minX, dy: startY - applicationFrame.minY)).press(forDuration: 0.01, + thenDragTo: origin.withOffset(CGVector(dx: x - applicationFrame.minX, dy: endY - applicationFrame.minY))) + } else if translation > 0 { scroll.swipeDown(velocity: .slow) } else { scroll.swipeUp(velocity: .slow) } } XCTFail("Sign-in and Create account actions must both fit the visible sign-in scroll viewport") diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 59741ec..bcefa2a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## A8 Usage guide and release handoff — implementation checkpoint (2026-10-17) +The exact `601a8fe` compact-auth diagnostic passed its one native UI case and showed complete Create account labels with normal word boundaries in both orientations. Actual landscape review nevertheless found Sign in partly behind the fixed appearance header: the accessibility ScrollView frame included that painted header region. The existing test now subtracts the actual header control extent from its visible-content region and makes a small bounded adjustment in an asserted noninteractive scroll gutter when a full native swipe overshoots. Complete Sign in/Create account frames, enabled/hittable controls, foreground app state and the 24-attempt budget remain mandatory. No app layout, action, system-UI dismissal or outcome assertion changed. This is an additional capture-readiness correction after a passing diagnostic, and both corrected full-OS images still require native review. + The old `64f5e75` large-phone log showed an opened Data export destination disappear after rotation, leaving the backing Account screen; another request rotation lost its local review presentation. Source review found both live and demo shells switched their entire TabView/NavigationSplitView branch on horizontal size class. Typed route models survived, while inline child navigation and sheet state belonged to the replaced branch. Both shells now keep a device-stable host: phones retain TabView and tablets retain native NavigationSplitView, with an explicit preferred detail column for collapsed windows and tab changes. Existing typed paths, account/makerspace cleanup, capabilities, width-based sidebar visibility and compact inventory selection conversion are unchanged. This removes the demonstrated structural state-lifetime risk; existing export/request rotation outcomes remain strict, and focused native execution plus resized-tablet/phone full-OS review are still required. The old `64f5e75` compact profile passed all 294 native tests and 36 UI journeys, but actual full-OS review found Create account split inside a word beside New here? at the largest accessibility text size. That row now stacks vertically at accessibility sizes with a full-width native Create account action; normal-size layout, full type scale and the existing sign-up handler remain unchanged. The named landscape sign-in capture showed an OS clock/blur overlay instead of the app, so it is not accepted as app visual proof. The existing dark/largest-text journey now reveals both complete native actions within the identified sign-in ScrollView using bounded native swipes, checks real app/control readiness before each capture, and preserves orientation, username and sign-in assertions. This removes whole-app gestures from that capture path without dismissing system UI or assuming its cause. Corrected-source native execution and actual full-OS review remain required. From 3d3ec7ed9bf052b44224a66d1babda0976f31558 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:52:52 +0530 Subject: [PATCH 61/98] Settle bounded auth scroll adjustments before capture Co-Authored-By: Codex --- SpaceWorksUITests/MainFlowTests.swift | 5 +++-- docs/CHANGELOG.md | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/SpaceWorksUITests/MainFlowTests.swift b/SpaceWorksUITests/MainFlowTests.swift index 36825c1..6943eca 100644 --- a/SpaceWorksUITests/MainFlowTests.swift +++ b/SpaceWorksUITests/MainFlowTests.swift @@ -128,8 +128,9 @@ final class MainFlowTests: XCTestCase { } print("Sign-in short reveal path=\(dragFrame) visibleViewport=\(viewport)") let origin = app.coordinate(withNormalizedOffset: .zero) - origin.withOffset(CGVector(dx: x - applicationFrame.minX, dy: startY - applicationFrame.minY)).press(forDuration: 0.01, - thenDragTo: origin.withOffset(CGVector(dx: x - applicationFrame.minX, dy: endY - applicationFrame.minY))) + origin.withOffset(CGVector(dx: x - applicationFrame.minX, dy: startY - applicationFrame.minY)).press(forDuration: 0.05, + thenDragTo: origin.withOffset(CGVector(dx: x - applicationFrame.minX, dy: endY - applicationFrame.minY)), + withVelocity: .slow, thenHoldForDuration: 0.3) } else if translation > 0 { scroll.swipeDown(velocity: .slow) } else { scroll.swipeUp(velocity: .slow) } } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bcefa2a..72853bc 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## A8 Usage guide and release handoff — implementation checkpoint (2026-10-17) +The exact `5205d18` compact-auth diagnostic passed portrait capture but correctly failed the strict landscape guard after 24 reveal attempts. Actual native event logs and the recording showed a 45.5-point adjustment use the default 500-pixel-per-second drag and release into a much larger scroll, alternating between the header-obscured action and a position beyond the second action. The same small gutter adjustment now uses the existing native slow-drag/0.3-second end-hold pattern already used by shared journey helpers, preventing that default release fling. The visible-region, noninteractive gutter, full-control/readiness requirements, 24-attempt budget and all app source remain unchanged. Corrected exact-source native execution and both actual full-OS captures remain required; this is not an accepted result from the failed diagnostic. + The exact `601a8fe` compact-auth diagnostic passed its one native UI case and showed complete Create account labels with normal word boundaries in both orientations. Actual landscape review nevertheless found Sign in partly behind the fixed appearance header: the accessibility ScrollView frame included that painted header region. The existing test now subtracts the actual header control extent from its visible-content region and makes a small bounded adjustment in an asserted noninteractive scroll gutter when a full native swipe overshoots. Complete Sign in/Create account frames, enabled/hittable controls, foreground app state and the 24-attempt budget remain mandatory. No app layout, action, system-UI dismissal or outcome assertion changed. This is an additional capture-readiness correction after a passing diagnostic, and both corrected full-OS images still require native review. The old `64f5e75` large-phone log showed an opened Data export destination disappear after rotation, leaving the backing Account screen; another request rotation lost its local review presentation. Source review found both live and demo shells switched their entire TabView/NavigationSplitView branch on horizontal size class. Typed route models survived, while inline child navigation and sheet state belonged to the replaced branch. Both shells now keep a device-stable host: phones retain TabView and tablets retain native NavigationSplitView, with an explicit preferred detail column for collapsed windows and tab changes. Existing typed paths, account/makerspace cleanup, capabilities, width-based sidebar visibility and compact inventory selection conversion are unchanged. This removes the demonstrated structural state-lifetime risk; existing export/request rotation outcomes remain strict, and focused native execution plus resized-tablet/phone full-OS review are still required. From 343400d9a11db49f437ae89207983a10e603b426 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:21:22 +0530 Subject: [PATCH 62/98] feat(b1): apply Gilroy content typography and native bars Co-Authored-By: Codex --- README.md | 2 +- SpaceWorks/AccountAccessView.swift | 10 +-- SpaceWorks/AccountDemo.swift | 2 +- SpaceWorks/AccountPasswordView.swift | 4 +- SpaceWorks/AccountViews.swift | 64 +++++++-------- SpaceWorks/AppGuideView.swift | 6 +- SpaceWorks/AuthView.swift | 26 +++--- SpaceWorks/BookingViews.swift | 34 ++++---- SpaceWorks/BrandTypography.swift | 55 +++++++++++++ SpaceWorks/DemoAccountLandingView.swift | 4 +- SpaceWorks/DemoDeletion.swift | 18 ++--- SpaceWorks/DemoMode.swift | 44 +++++------ SpaceWorks/DemoNotificationsView.swift | 6 +- SpaceWorks/EventViews.swift | 26 +++--- SpaceWorks/IntroductionView.swift | 4 +- SpaceWorks/MemberActionViews.swift | 24 +++--- SpaceWorks/MemberBookingsView.swift | 4 +- SpaceWorks/MemberCustomForm.swift | 2 +- SpaceWorks/MemberRoutes.swift | 2 +- SpaceWorks/MemberSettingsViews.swift | 18 ++--- SpaceWorks/MemberViews.swift | 69 ++++++++-------- SpaceWorks/NativeAppearance.swift | 9 ++- SpaceWorks/NotificationSettingsView.swift | 4 +- SpaceWorks/PasswordEntry.swift | 29 ++++++- SpaceWorks/PaymentsViews.swift | 34 ++++---- SpaceWorks/Platform/NativeScanner.swift | 2 +- SpaceWorks/Resources/Fonts/Gilroy-Bold.ttf | Bin 0 -> 137256 bytes SpaceWorks/Resources/Fonts/Gilroy-Medium.ttf | Bin 0 -> 143724 bytes SpaceWorks/Resources/Fonts/Gilroy-Regular.ttf | Bin 0 -> 145148 bytes .../Resources/Fonts/Gilroy-SemiBold.ttf | Bin 0 -> 139820 bytes .../Resources/Fonts/Gilroy-provenance.json | 42 ++++++++++ SpaceWorks/Resources/Fonts/README.md | 6 +- SpaceWorks/RootView.swift | 9 ++- SpaceWorks/StaffHandoverView.swift | 26 +++--- SpaceWorks/StaffViews.swift | 30 +++---- SpaceWorksTests/BrandTypographyTests.swift | 74 ++++++++++++++++++ docs/CHANGELOG.md | 8 ++ docs/WHITE-LABEL.md | 2 +- scripts/audit_bundle.py | 2 +- scripts/generate.py | 2 +- scripts/tests/test_audit_bundle.py | 4 +- scripts/tests/test_brand_fonts.py | 49 ++++++++++++ 42 files changed, 512 insertions(+), 244 deletions(-) create mode 100644 SpaceWorks/BrandTypography.swift create mode 100644 SpaceWorks/Resources/Fonts/Gilroy-Bold.ttf create mode 100644 SpaceWorks/Resources/Fonts/Gilroy-Medium.ttf create mode 100644 SpaceWorks/Resources/Fonts/Gilroy-Regular.ttf create mode 100644 SpaceWorks/Resources/Fonts/Gilroy-SemiBold.ttf create mode 100644 SpaceWorks/Resources/Fonts/Gilroy-provenance.json create mode 100644 SpaceWorksTests/BrandTypographyTests.swift create mode 100644 scripts/tests/test_brand_fonts.py diff --git a/README.md b/README.md index 4bdd2e5..7831bd6 100644 --- a/README.md +++ b/README.md @@ -28,4 +28,4 @@ Choose a server on launch or lock one HTTPS origin in `branding/brand.json`. Deb The backend and owner provisioning are deferred. Unsupported native contracts remain unavailable; see [backend dependencies](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/issues/3). Real attestation, providers, push, checkout, account deletion and store distribution require the deployment inputs and verification in the release guide. -Brand configuration and signing: [WHITE-LABEL.md](docs/WHITE-LABEL.md). Space Works uses the pinned Design semantic colors, static logo masters, native system text and licensed Unbounded accent font. Gilroy is not embedded without app-embedding permission. Never commit signing material, provider secrets or test credentials. +Brand configuration and signing: [WHITE-LABEL.md](docs/WHITE-LABEL.md). Space Works uses the pinned Design semantic colors, static logo masters, Gilroy content typography with Dynamic Type/Bold Text, native platform navigation and one licensed Unbounded accent phrase. Owner-supplied font provenance and applicable licensing are recorded with the bundled faces. Never commit signing material, provider secrets or test credentials. diff --git a/SpaceWorks/AccountAccessView.swift b/SpaceWorks/AccountAccessView.swift index cf227ef..db9a3a7 100644 --- a/SpaceWorks/AccountAccessView.swift +++ b/SpaceWorks/AccountAccessView.swift @@ -10,7 +10,7 @@ struct AccountAccessView: View { BrandWordmark() if form.isDemo { Label("Demo mode · no account changes are made", systemImage: "info.circle").foregroundStyle(Color.swMuted) } SWCard { - Text(heading).font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text(heading).brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) Text(explanation).foregroundStyle(Color.swMuted) switch form.step { case .signUp: @@ -40,7 +40,7 @@ struct AccountAccessView: View { resend(String(localized: "Send another code")) { await form.resendEmail() } case .created, .resetComplete, .unavailable: Image(systemName: form.step == .unavailable ? "person.crop.circle.badge.questionmark" : "checkmark.circle") - .font(.largeTitle).foregroundStyle(Color.swAccentInk).accessibilityHidden(true) + .brandFont(.largeTitle).foregroundStyle(Color.swAccentInk).accessibilityHidden(true) Button(form.step == .unavailable ? "Back to sign in" : "Go to sign in", action: close).buttonStyle(SWPrimary()) } if let error = form.error { SWError(message: error) } @@ -101,7 +101,7 @@ struct AccountAccessView: View { } private func input(_ title: String, value: Binding, id: String, type: UITextContentType) -> some View { VStack(alignment: .leading, spacing: 8) { - Text(title).font(.headline) + Text(title).brandFont(.headline) TextField(title, text: value, prompt: Text(title).foregroundColor(Color.swMuted)).textContentType(type) .keyboardType(type == .emailAddress ? .emailAddress : type == .telephoneNumber ? .phonePad : .default) .textInputAutocapitalization(type == .name ? .words : .never).autocorrectionDisabled() @@ -112,7 +112,7 @@ struct AccountAccessView: View { } private func codeInput(_ title: String) -> some View { VStack(alignment: .leading, spacing: 8) { - Text(title).font(.headline) + Text(title).brandFont(.headline) TextField(title, text: $form.code, prompt: Text(title).foregroundColor(Color.swMuted)).keyboardType(.numberPad).textContentType(.oneTimeCode) .textInputAutocapitalization(.never).autocorrectionDisabled().monospacedDigit() .focused($focused, equals: "account-code").accessibilityIdentifier("account-code").padding(14).frame(minHeight: 48) @@ -133,7 +133,7 @@ struct AccountAccessView: View { .disabled(form.busy || tick.date < form.resendAfter) if tick.date < form.resendAfter { Text(CooldownCopy.retryMessage(until: form.resendAfter, now: tick.date)) - .font(.caption).foregroundStyle(Color.swMuted) + .brandFont(.caption).foregroundStyle(Color.swMuted) } } } diff --git a/SpaceWorks/AccountDemo.swift b/SpaceWorks/AccountDemo.swift index fbd81f8..70714d6 100644 --- a/SpaceWorks/AccountDemo.swift +++ b/SpaceWorks/AccountDemo.swift @@ -110,7 +110,7 @@ struct DemoAccountProviderLinks: View { @State private var outcome = DemoAccountLinkOutcome.success var body: some View { SWCard { - Text("Preview provider linking").font(.headline) + Text("Preview provider linking").brandFont(.headline) Text("Demo mode. No provider account is connected.").foregroundStyle(Color.swMuted) Picker("Preview result", selection: $outcome) { ForEach(DemoAccountLinkOutcome.allCases, id: \.self) { Text(LocalizedStringKey($0.rawValue)) } }.frame(minHeight: 48) if !state.providers.contains(where: { $0.provider == "google" }) { GoogleProviderButton(wide: false) { provider = "google" } } diff --git a/SpaceWorks/AccountPasswordView.swift b/SpaceWorks/AccountPasswordView.swift index 0fef792..41ef5a6 100644 --- a/SpaceWorks/AccountPasswordView.swift +++ b/SpaceWorks/AccountPasswordView.swift @@ -7,14 +7,14 @@ struct AccountPasswordView: View { @State private var confirmation = "" var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text("Change password").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text("Change password").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) Text("Choose a unique password. Your server checks its password policy.").foregroundStyle(Color.swMuted) if state.isDemo { Text("Demo only. Use DemoPass!123 as the current password. No password will be changed.").foregroundStyle(Color.swMuted) } SWCard { PasswordControl(title: String(localized: "Current password"), text: $current, identifier: "account-password-current") PasswordControl(title: String(localized: "New password"), text: $new, newPassword: true, identifier: "account-password-new") PasswordControl(title: String(localized: "Confirm password"), text: $confirmation, newPassword: true, identifier: "account-password-confirm") - Text("If you don't have a current password, sign out and use Forgot password to recover access.").font(.subheadline).foregroundStyle(Color.swMuted) + Text("If you don't have a current password, sign out and use Forgot password to recover access.").brandFont(.subheadline).foregroundStyle(Color.swMuted) if let error = state.error { SWError(message: error) } if let notice = state.notice { Label(notice, systemImage: "checkmark.circle").foregroundStyle(Color.swMuted).accessibilityIdentifier("account-password-notice") } TimelineView(.periodic(from: .now, by: 1)) { tick in diff --git a/SpaceWorks/AccountViews.swift b/SpaceWorks/AccountViews.swift index 4b87a07..655e261 100644 --- a/SpaceWorks/AccountViews.swift +++ b/SpaceWorks/AccountViews.swift @@ -12,7 +12,7 @@ struct AccountView: View { @Environment(\.dismiss) private var dismiss var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text("Account and security").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text("Account and security").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) Text("Manage your sign-in, devices, membership, and personal data.").foregroundStyle(Color.swMuted) if state.isDemo { Label("Demo account · sample data", systemImage: "info.circle").foregroundStyle(Color.swMuted) } SWCard { @@ -31,13 +31,13 @@ struct AccountView: View { if state.makerspaceID == nil { Text("Join a makerspace to manage membership records here.").foregroundStyle(Color.swMuted) } } SWCard { - Text("Privacy and help").font(.title2.bold()) + Text("Privacy and help").brandFont(.title2, weight: .bold) NavigationLink("App guide") { AppGuideView(isDemo: state.isDemo) }.frame(minHeight: 48).accessibilityIdentifier("app-guide") if let url = AccountLinks.safe(privacyURL) { Link("Privacy policy", destination: url).frame(minHeight: 48) } else { Text("Your makerspace hasn't provided a privacy policy link.").foregroundStyle(Color.swMuted) } if let url = AccountLinks.safe(supportURL) { Link("Contact support", destination: url).frame(minHeight: 48) } else { Text("Ask your makerspace for its support contact.").foregroundStyle(Color.swMuted) } - Text("Account deletion").font(.headline) + Text("Account deletion").brandFont(.headline) #if DEBUG if state.isDemo, let onDemoDelete { NavigationLink("Preview account deletion") { @@ -68,13 +68,13 @@ private struct AccountMenuRow: View { if dynamicTypeSize.isAccessibilitySize { // Decorative glyphs must not squeeze scaled words into narrow columns. VStack(alignment: .leading, spacing: 8) { - Text(title).font(.headline).fixedSize(horizontal: false, vertical: true) - Text(verbatim: detail).font(.subheadline).foregroundStyle(Color.swMuted).fixedSize(horizontal: false, vertical: true) + Text(title).brandFont(.headline).fixedSize(horizontal: false, vertical: true) + Text(verbatim: detail).brandFont(.subheadline).foregroundStyle(Color.swMuted).fixedSize(horizontal: false, vertical: true) }.frame(maxWidth: .infinity, alignment: .leading) } else { HStack(alignment: .top, spacing: 16) { Image(systemName: icon).font(.title2).accessibilityHidden(true) - VStack(alignment: .leading, spacing: 8) { Text(title).font(.headline); Text(verbatim: detail).font(.subheadline).foregroundStyle(Color.swMuted) } + VStack(alignment: .leading, spacing: 8) { Text(title).brandFont(.headline); Text(verbatim: detail).brandFont(.subheadline).foregroundStyle(Color.swMuted) } Spacer(minLength: 0); Image(systemName: "chevron.right").accessibilityHidden(true) } } @@ -107,20 +107,20 @@ private struct AccountSecurityView: View { @State private var action: String? var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text("Two-step sign-in").font(.largeTitle.bold()) + Text("Two-step sign-in").brandFont(.largeTitle, weight: .bold) Text("Use an authenticator as an extra check when you sign in.").foregroundStyle(Color.swMuted) AccountFeedback(load: state.securityLoad) { await state.loadSecurity() } AccountMessages(state: state) if let mfa = state.mfa { SWCard { - Label(mfa.enabled ? "Authenticator enrolled" : "Authenticator not enrolled", systemImage: "lock.shield").font(.headline) + Label(mfa.enabled ? "Authenticator enrolled" : "Authenticator not enrolled", systemImage: "lock.shield").brandFont(.headline) if mfa.required { Text("Your account requires two-step sign-in.").foregroundStyle(Color.swMuted) } if !mfa.available && !mfa.enabled { Text("Two-step sign-in is unavailable on this server.").foregroundStyle(Color.swMuted) } if !mfa.enabled && mfa.available && !state.isDemo { Text("Enable two-step sign-in in your makerspace's web portal, then sign in again on this device.").foregroundStyle(Color.swMuted) } } if mfa.enabled || state.canStartMFA { - Text("Current password").font(.headline) - if state.isDemo { Text("Use DemoPass!123 in this demo. No real security setting changes.").font(.subheadline).foregroundStyle(Color.swMuted) } + Text("Current password").brandFont(.headline) + if state.isDemo { Text("Use DemoPass!123 in this demo. No real security setting changes.").brandFont(.subheadline).foregroundStyle(Color.swMuted) } PasswordControl(title: String(localized: "Current password"), text: $state.password, identifier: "account-current-password", focus: $focused).disabled(state.busy) Text("Changing your authenticator or recovery codes ends your existing sessions.").foregroundStyle(Color.swMuted) if state.canStartMFA { Button("Enroll authenticator") { action = "enroll" }.buttonStyle(SWPrimary()).disabled(state.password.isEmpty) } @@ -148,7 +148,7 @@ struct AccountSecurityCompletionView: View { @State private var saved = false var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text(codes.isEmpty ? "Sign in again" : "Save your recovery codes").font(.largeTitle.bold()) + Text(codes.isEmpty ? "Sign in again" : "Save your recovery codes").brandFont(.largeTitle, weight: .bold) Text(message).foregroundStyle(Color.swMuted) if !codes.isEmpty { SWCard { Text(codes.joined(separator: "\n")).font(.body.monospaced()).textSelection(.enabled).privacySensitive() } @@ -167,20 +167,20 @@ private struct AccountPhoneView: View { @FocusState private var focused: Field? var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text("Phone sign-in").font(.largeTitle.bold()) + Text("Phone sign-in").brandFont(.largeTitle, weight: .bold) Text("Verify a number to use it for sign-in.").foregroundStyle(Color.swMuted) AccountMessages(state: state) - if let status = state.phoneStatus, status.verified { Label(status.phone_e164, systemImage: "checkmark.seal").font(.headline) } + if let status = state.phoneStatus, status.verified { Label(status.phone_e164, systemImage: "checkmark.seal").brandFont(.headline) } if let pending = state.pendingPhone { - Text("Enter the code sent to \(pending)").font(.headline) + Text("Enter the code sent to \(pending)").brandFont(.headline) TextField("Verification code", text: $state.code).textContentType(.oneTimeCode).keyboardType(.numberPad).modifier(AccountInput()).focused($focused, equals: .code).disabled(state.busy).accessibilityIdentifier("account-phone-code") - if state.isDemo { Text("Use 123456 in this demo. No message was sent.").font(.subheadline).foregroundStyle(Color.swMuted) } + if state.isDemo { Text("Use 123456 in this demo. No message was sent.").brandFont(.subheadline).foregroundStyle(Color.swMuted) } Button("Verify phone number") { Task { await state.confirmPhone() } }.buttonStyle(SWPrimary()).disabled(!state.canRequest).accessibilityIdentifier("account-phone-verify") Button("Change number") { state.changePhoneNumber() }.frame(minHeight: 48).disabled(state.busy) } else { - Text("Country calling code").font(.headline) + Text("Country calling code").brandFont(.headline) TextField("Calling code", text: $state.phoneCountry).keyboardType(.phonePad).modifier(AccountInput()).focused($focused, equals: .country).disabled(state.busy) - Text("Phone number").font(.headline) + Text("Phone number").brandFont(.headline) TextField("Phone number", text: $state.phone).textContentType(.telephoneNumber).keyboardType(.phonePad).modifier(AccountInput()).focused($focused, equals: .phone).disabled(state.busy).accessibilityIdentifier("account-phone-number") } TimelineView(.periodic(from: .now, by: 1)) { tick in @@ -207,11 +207,11 @@ private struct AccountProvidersView: View { @State private var removing: String? var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text("Connected sign-in methods").font(.largeTitle.bold()) + Text("Connected sign-in methods").brandFont(.largeTitle, weight: .bold) AccountFeedback(load: state.providersLoad) { await state.loadProviders() }; AccountMessages(state: state) ForEach(state.providers, id: \.provider) { provider in SWCard { - Text(NativeStatusCopy.label(provider.provider)).font(.headline) + Text(NativeStatusCopy.label(provider.provider)).brandFont(.headline) Text("Connected \(provider.created_at.formatted(date: .abbreviated, time: .omitted))").foregroundStyle(Color.swMuted) Button("Remove sign-in method", role: .destructive) { removing = provider.provider }.frame(minHeight: 48).disabled(!state.canRequest) } @@ -234,12 +234,12 @@ private struct AccountSessionsView: View { @State private var session: Components.Schemas.AccountSession? var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 24) { - Text("Sessions").font(.largeTitle.bold()); AccountMessages(state: state) + Text("Sessions").brandFont(.largeTitle, weight: .bold); AccountMessages(state: state) AccountFeedback(load: state.sessionsLoad) { await state.loadSessions() } ForEach(state.sessions, id: \.id) { row in SWCard { - Label(row.kind.rawValue == "device" ? "Device session" : "Browser session", systemImage: row.kind.rawValue == "device" ? "iphone" : "globe").font(.headline) - if row.current { Text("This session").foregroundStyle(Color.swAccentInk).font(.headline) } + Label(row.kind.rawValue == "device" ? "Device session" : "Browser session", systemImage: row.kind.rawValue == "device" ? "iphone" : "globe").brandFont(.headline) + if row.current { Text("This session").foregroundStyle(Color.swAccentInk).brandFont(.headline) } Text("Started \(row.created_at.formatted(date: .abbreviated, time: .shortened))").foregroundStyle(Color.swMuted) if let last = row.last_used_at { Text("Last used \(last.formatted(date: .abbreviated, time: .shortened))").foregroundStyle(Color.swMuted) } Button(row.current ? "Sign out this session" : "Sign out session", role: .destructive) { session = row }.frame(minHeight: 48).disabled(!state.canRequest).accessibilityIdentifier(row.current ? "account-revoke-current-session" : "account-revoke-session-\(row.id)") @@ -260,15 +260,15 @@ private struct AccountDevicesView: View { @State private var grant: Components.Schemas.DeviceGrant? var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 24) { - Text("Devices").font(.largeTitle.bold()) + Text("Devices").brandFont(.largeTitle, weight: .bold) Text("These are native app sessions on your account. Browser sessions are managed separately by your makerspace.").foregroundStyle(Color.swMuted) AccountMessages(state: state) - Text("Registered devices").font(.title2.bold()) + Text("Registered devices").brandFont(.title2, weight: .bold) AccountFeedback(load: state.grantsLoad) { await state.loadGrants() } ForEach(state.grants, id: \.id) { row in SWCard { - Text(NativeStatusCopy.label(row.platform)).font(.headline) - if state.currentDeviceGrantID == row.id { Text("This device").font(.headline).foregroundStyle(Color.swAccentInk) } + Text(NativeStatusCopy.label(row.platform)).brandFont(.headline) + if state.currentDeviceGrantID == row.id { Text("This device").brandFont(.headline).foregroundStyle(Color.swAccentInk) } Text(NativeStatusCopy.label(row.status)); Text("Last used \(row.last_used_at.formatted(date: .abbreviated, time: .shortened))").foregroundStyle(Color.swMuted) Button("Sign out device", role: .destructive) { grant = row }.frame(minHeight: 48).disabled(!state.canRequest || row.status != "active").accessibilityIdentifier("account-revoke-device-\(row.id)") } @@ -289,13 +289,13 @@ private struct AccountMembershipView: View { @FocusState private var reasonFocused: Bool var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text(state.makerspaceName).font(.largeTitle.bold()); AccountMessages(state: state) + Text(state.makerspaceName).brandFont(.largeTitle, weight: .bold); AccountMessages(state: state) AccountFeedback(load: state.membershipLoad) { await state.loadMembership() } if let membership = state.membership { - Text(NativeStatusCopy.label(membership.status.rawValue)).font(.headline) + Text(NativeStatusCopy.label(membership.status.rawValue)).brandFont(.headline) if let date = membership.renewal_cancelled_at { Text("Renewal cancelled \(date.formatted(date: .abbreviated, time: .omitted))").foregroundStyle(Color.swMuted) } if membership.status.rawValue == "active" { - Text("Reason").font(.headline) + Text("Reason").brandFont(.headline) TextField("Tell your makerspace why", text: $state.membershipReason, axis: .vertical).focused($reasonFocused).lineLimit(3...6).modifier(AccountInput()).disabled(state.busy).accessibilityIdentifier("account-membership-reason") Text("Leaving ends your access to this makerspace. Outstanding loans or charges may need to be resolved first.").foregroundStyle(Color.swMuted) if membership.renewal_cancelled_at == nil { Button("Cancel membership renewal") { decision = "renewal" }.frame(minHeight: 48).disabled(!state.canRequest || state.membershipReason.isEmpty) } @@ -329,14 +329,14 @@ private struct AccountExportsView: View { @State private var share = false var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 24) { - Text("Your data").font(.largeTitle.bold()) + Text("Your data").brandFont(.largeTitle, weight: .bold) Text("Request an export of your personal data held by this makerspace. Keep downloaded files somewhere private.").foregroundStyle(Color.swMuted) AccountMessages(state: state); AccountFeedback(load: state.exportsLoad) { await state.loadExports() } Button("Request data export") { Task { await state.requestExport() } }.buttonStyle(SWPrimary()).disabled(!state.canRequest).accessibilityIdentifier("account-request-export") if state.downloading { ProgressView("Preparing your archive…") } ForEach(state.exports, id: \.id) { row in SWCard { - Text(row.created_at, format: .dateTime.day().month().year()).font(.headline) + Text(row.created_at, format: .dateTime.day().month().year()).brandFont(.headline) Text(NativeStatusCopy.label(row.status.value1.rawValue)).foregroundStyle(Color.swMuted) if !row.failure_detail.isEmpty { SWError(message: row.failure_detail) } TimelineView(.periodic(from: .now, by: 1)) { tick in @@ -357,7 +357,7 @@ private struct AccountExportsView: View { ScrollView { VStack(spacing: 24) { Image(systemName: "doc.zipper").font(.largeTitle).accessibilityHidden(true) - Text(state.isDemo ? "Demo data export" : "Your data export").font(.title.bold()) + Text(state.isDemo ? "Demo data export" : "Your data export").brandFont(.title, weight: .bold) Text("Save this archive to a private location or share it with someone you trust.").foregroundStyle(Color.swMuted) Button("Save or share archive") { share = true }.buttonStyle(SWPrimary()).accessibilityIdentifier("account-share-export") }.padding(24).frame(maxWidth: 640).frame(maxWidth: .infinity) diff --git a/SpaceWorks/AppGuideView.swift b/SpaceWorks/AppGuideView.swift index cc93a9f..d3ff06a 100644 --- a/SpaceWorks/AppGuideView.swift +++ b/SpaceWorks/AppGuideView.swift @@ -12,7 +12,7 @@ struct AppGuideView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text("App guide").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text("App guide").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) Text("Make the most of your makerspace, at your own pace.").foregroundStyle(Color.swMuted) if isDemo { Text("Demo changes stay in this preview.").foregroundStyle(Color.swMuted) } SWCard { @@ -60,11 +60,11 @@ struct AppGuideView: View { } }.foregroundStyle(Color.swInk) SWCard { - Text("About this app").font(.title2.bold()) + Text("About this app").brandFont(.title2, weight: .bold) let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "—" let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "—" Text("Version \(version) (\(build))").textSelection(.enabled) - if let serverLabel { Text("Server").font(.headline); Text(verbatim: serverLabel).textSelection(.enabled) } + if let serverLabel { Text("Server").brandFont(.headline); Text(verbatim: serverLabel).textSelection(.enabled) } if let privacy = Brand.privacyURL.flatMap(URL.init(string:)), let safe = AccountLinks.safe(privacy) { Link("Privacy policy", destination: safe).frame(minHeight: 48) } if let support = Brand.supportURL.flatMap(URL.init(string:)), let safe = AccountLinks.safe(support) { Link("Contact support", destination: safe).frame(minHeight: 48) } Text("Ask your makerspace for help with unavailable features, permissions or account records.").foregroundStyle(Color.swMuted) diff --git a/SpaceWorks/AuthView.swift b/SpaceWorks/AuthView.swift index d5fbee7..7dd5561 100644 --- a/SpaceWorks/AuthView.swift +++ b/SpaceWorks/AuthView.swift @@ -63,7 +63,7 @@ struct PasswordChangeView: View { var body: some View { ScrollView { SWCard { - Text("Choose a new password").font(.largeTitle.bold()) + Text("Choose a new password").brandFont(.largeTitle, weight: .bold) Text("Replace your temporary password to continue.").foregroundStyle(Color.swMuted) PasswordControl(title: String(localized: "Current password"), text: $current, identifier: "current-password") PasswordControl(title: String(localized: "New password"), text: $new, newPassword: true, identifier: "new-password") @@ -99,7 +99,7 @@ struct SWError: View { } struct SWPrimary: ButtonStyle { func makeBody(configuration: Configuration) -> some View { - configuration.label.font(.headline).frame(maxWidth: .infinity, minHeight: 48).padding(.horizontal, 16) + configuration.label.brandFont(.headline).frame(maxWidth: .infinity, minHeight: 48).padding(.horizontal, 16) .foregroundStyle(Color.swOnAccent) .background(configuration.isPressed ? Color.swAccentActive : Color.swAccent, in: RoundedRectangle(cornerRadius: 10)) } @@ -117,16 +117,16 @@ struct SignInView: View { HStack(alignment: .center, spacing: 40) { VStack(alignment: .leading, spacing: 24) { BrandWordmark(compact: geometry.size.height < 600) - Text("Welcome back.").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text("Welcome back.").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) Text("Sign in to your makerspace account.").foregroundStyle(Color.swMuted) SWCard { - Text("Sign in").font(.title2.bold()) + Text("Sign in").brandFont(.title2, weight: .bold) if auth.configuration == nil { Text("Couldn't load sign-in options. Try again.").foregroundStyle(Color.swMuted) if let error = auth.error { SWError(message: error) } Button("Try again") { Task { await auth.retryRestore() } }.buttonStyle(SWPrimary()) } else if !auth.passwordEnabled { - Text("Sign-in isn't available here").font(.headline) + Text("Sign-in isn't available here").brandFont(.headline) Text("Ask your makerspace for help, or check the server address.").foregroundStyle(Color.swMuted) } else { field("Email or username") { @@ -169,7 +169,7 @@ struct SignInView: View { VStack(alignment: .leading, spacing: 24) { BrandWordmark(spacious: true) Text("Less searching.\nMore making.").font(.custom("Unbounded-Regular", size: 24, relativeTo: .title2).weight(.medium)) - Text("Borrow hardware. Make room for your next idea.").font(.title3) + Text("Borrow hardware. Make room for your next idea.").brandFont(.title3) }.padding(40).frame(maxWidth: 460, minHeight: 460, alignment: .leading) .background(Color.swSuccess, in: RoundedRectangle(cornerRadius: 28)).foregroundStyle(Color.swOnSuccess) .accessibilityHidden(true) @@ -181,7 +181,7 @@ struct SignInView: View { } private func field(_ title: String, @ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 8) { - Text(title).font(.headline) + Text(title).brandFont(.headline) content().padding(.horizontal, 12).frame(minHeight: 52) .background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)) @@ -206,7 +206,7 @@ struct BrandWordmark: View { Image("BrandStacked").resizable().scaledToFit().frame(width: spacious ? 280 : 180, height: spacious ? 100 : 64) .padding(16).accessibilityLabel(Text(verbatim: Brand.appName)) } - } else { Text(verbatim: Brand.appName).font(.headline).foregroundStyle(Color.swAccentInk) } + } else { Text(verbatim: Brand.appName).brandFont(.headline).foregroundStyle(Color.swAccentInk) } } } @@ -219,13 +219,13 @@ struct MFAView: View { SWCard { Image(systemName: "lock.shield").font(.largeTitle).foregroundStyle(Color.swAccentInk).accessibilityHidden(true) if auth.stage == .recoveryCodes { - Text("Save your recovery codes").font(.largeTitle.bold()) + Text("Save your recovery codes").brandFont(.largeTitle, weight: .bold) Text("Keep these somewhere safe. Each code works once.").foregroundStyle(Color.swMuted) ForEach(auth.recoveryCodes, id: \.self) { Text($0).monospacedDigit().textSelection(.enabled) } Toggle("I've saved my recovery codes", isOn: $codesSaved).frame(minHeight: 48) Button("Continue") { auth.confirmRecoveryCodes() }.buttonStyle(SWPrimary()).disabled(!codesSaved) } else { - Text(auth.mfa?.enrollment_required == true ? "Add extra security" : "Check your authenticator").font(.largeTitle.bold()) + Text(auth.mfa?.enrollment_required == true ? "Add extra security" : "Check your authenticator").brandFont(.largeTitle, weight: .bold) if let enrollment = auth.enrollment { Text("Add this account to your authenticator app, then enter its six-digit code.").foregroundStyle(Color.swMuted) QRCodeView(value: enrollment.provisioning_uri) @@ -236,7 +236,7 @@ struct MFAView: View { } else { Text(recoveryEntry ? "Enter one of your saved recovery codes." : "Enter the six-digit code from your authenticator app.").foregroundStyle(Color.swMuted) } - Text(recoveryEntry ? "Recovery code" : "Authentication code").font(.headline) + Text(recoveryEntry ? "Recovery code" : "Authentication code").brandFont(.headline) TextField(recoveryEntry ? "Recovery code" : "Authentication code", text: $auth.code, prompt: Text(recoveryEntry ? "Recovery code" : "Authentication code").foregroundColor(Color.swMuted)) .textInputAutocapitalization(.never).autocorrectionDisabled().padding(16) @@ -278,14 +278,14 @@ struct MakerspacePicker: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text("Choose a makerspace").font(.largeTitle.bold()) + Text("Choose a makerspace").brandFont(.largeTitle, weight: .bold) if auth.memberships.isEmpty { Text("You haven’t joined a makerspace yet.").foregroundStyle(Color.swMuted) } ForEach(auth.memberships, id: \.id) { membership in Button { Task { await auth.select(membership.id) } } label: { SWCard { - Text(membership.slug).font(.title2.bold()) + Text(membership.slug).brandFont(.title2, weight: .bold) Text(membership.role_name).foregroundStyle(Color.swMuted) } }.buttonStyle(.plain).accessibilityIdentifier("makerspace-\(membership.id)") diff --git a/SpaceWorks/BookingViews.swift b/SpaceWorks/BookingViews.swift index 42dd22a..5f75ff7 100644 --- a/SpaceWorks/BookingViews.swift +++ b/SpaceWorks/BookingViews.swift @@ -9,15 +9,15 @@ struct SpaceDiscoveryView: View { var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 24) { - Text("Find a space").font(.largeTitle.bold()) + Text("Find a space").brandFont(.largeTitle, weight: .bold) Text("Make room for your next idea. Availability and approvals come from your makerspace.").foregroundStyle(Color.swMuted) - if isDemo { Text("Demo mode · sample spaces").font(.caption.bold()).foregroundStyle(Color.swMuted) } + if isDemo { Text("Demo mode · sample spaces").brandFont(.caption, weight: .bold).foregroundStyle(Color.swMuted) } LoadFeedback(load: member.spacesLoad, retryAfter: member.deadline("spaces")) { await member.loadSpaces() } if member.spacesLoad == .ready && member.spaces.isEmpty { SWCard { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } } ForEach(member.spaces, id: \.public_token) { space in NavigationLink(value: MemberRoute.space(space.public_token)) { SWCard { - Label(space.name, systemImage: "building.2.crop.circle").font(.title2.bold()) + Label(space.name, systemImage: "building.2.crop.circle").brandFont(.title2, weight: .bold) Text(space.description).foregroundStyle(Color.swMuted) Text(space.location).foregroundStyle(Color.swMuted) Text("Capacity: \(space.capacity)") @@ -45,11 +45,11 @@ struct SpaceBookingView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text(state.space.name).font(.largeTitle.bold()) - if isDemo { Text("Demo mode · no real booking is made").font(.caption.bold()).foregroundStyle(Color.swMuted) } + Text(state.space.name).brandFont(.largeTitle, weight: .bold) + if isDemo { Text("Demo mode · no real booking is made").brandFont(.caption, weight: .bold).foregroundStyle(Color.swMuted) } if let result = state.result { SWCard { - Label(result.status.value1 == .pending ? "Booking request received" : "Space booked", systemImage: "checkmark.circle").font(.title2.bold()) + Label(result.status.value1 == .pending ? "Booking request received" : "Space booked", systemImage: "checkmark.circle").brandFont(.title2, weight: .bold) Text(result.status.value1 == .pending ? "Staff must approve this request before the slot is confirmed." : "Your selected slot is confirmed and booked.").foregroundStyle(Color.swMuted) Text("Find your booking in Activity. Its reference and cancellation options are loaded from your account.").foregroundStyle(Color.swMuted) Button("View bookings", action: viewActivity).buttonStyle(SWPrimary()) @@ -57,8 +57,8 @@ struct SpaceBookingView: View { } } else { SWCard { - Text("Request this space").font(.title2.bold()) - Text("Times are shown in your device timezone: \(TimeZone.current.identifier).").font(.caption).foregroundStyle(Color.swMuted) + Text("Request this space").brandFont(.title2, weight: .bold) + Text("Times are shown in your device timezone: \(TimeZone.current.identifier).").brandFont(.caption).foregroundStyle(Color.swMuted) DatePicker("Starts", selection: $state.start).frame(minHeight: 48) DatePicker("Ends", selection: $state.end).frame(minHeight: 48) TimelineView(.periodic(from: .now, by: 1)) { tick in @@ -68,18 +68,18 @@ struct SpaceBookingView: View { }.disabled(state.busy) if let availability = state.availability { SWCard { - Text("Opening hours").font(.title2.bold()) - Text(availability.timezone).font(.caption).foregroundStyle(Color.swMuted) + Text("Opening hours").brandFont(.title2, weight: .bold) + Text(availability.timezone).brandFont(.caption).foregroundStyle(Color.swMuted) if availability.opening_intervals.isEmpty { Text("The makerspace is closed in this window.").foregroundStyle(Color.swMuted) } ForEach(Array(availability.opening_intervals.enumerated()), id: \.offset) { _, interval in Text(intervalLabel(interval.starts_at, interval.ends_at, timezone: availability.timezone)) } if let intervals = availability.availability { - Text("Booked times").font(.headline) + Text("Booked times").brandFont(.headline) if intervals.isEmpty { Text("No confirmed bookings are shown in this window.").foregroundStyle(Color.swMuted) } ForEach(Array(intervals.enumerated()), id: \.offset) { _, interval in Text(intervalLabel(interval.starts_at, interval.ends_at, timezone: availability.timezone)) } } else { Text("This makerspace doesn't share booked times. Your request is checked when submitted.").foregroundStyle(Color.swMuted) } } SWCard { - Text("Booking details").font(.title2.bold()); MemberCustomFields(form: form) + Text("Booking details").brandFont(.title2, weight: .bold); MemberCustomFields(form: form) Text("Review your times and answers. The server checks availability again when you submit.").foregroundStyle(Color.swMuted) if let formError { SWError(message: formError) } TimelineView(.periodic(from: .now, by: 1)) { tick in @@ -127,12 +127,12 @@ struct MemberBookingDetailView: View { var body: some View { ScrollView { SWCard { - if isDemo { Text("Demo mode · sample booking").font(.caption.bold()).foregroundStyle(Color.swMuted) } - Text(current.space_name).font(.largeTitle.bold()) + if isDemo { Text("Demo mode · sample booking").brandFont(.caption, weight: .bold).foregroundStyle(Color.swMuted) } + Text(current.space_name).brandFont(.largeTitle, weight: .bold) Text(intervalLabel(current.starts_at, current.ends_at, timezone: current.timezone)) - Text(current.timezone).font(.caption).foregroundStyle(Color.swMuted) - Text(NativeStatusCopy.label(current.status.value1.rawValue)).font(.headline) - Text("Booking reference").font(.headline); Text(current.public_token).font(.body.monospaced()).textSelection(.enabled) + Text(current.timezone).brandFont(.caption).foregroundStyle(Color.swMuted) + Text(NativeStatusCopy.label(current.status.value1.rawValue)).brandFont(.headline) + Text("Booking reference").brandFont(.headline); Text(current.public_token).font(.body.monospaced()).textSelection(.enabled) if let error { SWError(message: error) } if current.can_cancel { Text("Cancellation cutoff: \(current.cancellation_cutoff_at.formatted(date: .abbreviated, time: .shortened))").foregroundStyle(Color.swMuted) diff --git a/SpaceWorks/BrandTypography.swift b/SpaceWorks/BrandTypography.swift new file mode 100644 index 0000000..c4b9f41 --- /dev/null +++ b/SpaceWorks/BrandTypography.swift @@ -0,0 +1,55 @@ +import SwiftUI +import UIKit + +/// Choose the supplied face by PostScript name: all upstream OS/2 weight fields are 400. +enum BrandTypography { + enum Weight: Int, CaseIterable { + case regular, medium, semibold, bold + func face(boldText: Bool) -> String { + let index = boldText ? [2, 3, 3, 3][rawValue] : rawValue + return ["Gilroy-Regular", "Gilroy-Medium", "Gilroy-SemiBold", "Gilroy-Bold"][index] + } + } + static func size(_ style: Font.TextStyle) -> CGFloat { + switch style { + case .largeTitle: return 34 + case .title: return 28 + case .title2: return 22 + case .title3: return 20 + case .headline, .body: return 17 + case .callout: return 16 + case .subheadline: return 15 + case .footnote: return 13 + case .caption: return 12 + case .caption2: return 11 + @unknown default: return 17 + } + } + static func font(_ style: Font.TextStyle, weight: Weight, boldText: Bool) -> Font { + .custom(weight.face(boldText: boldText), size: size(style), relativeTo: style) + } + static func uiFont(style: UIFont.TextStyle = .body, weight: Weight = .regular, + traits: UITraitCollection, boldText: Bool) -> UIFont { + let base = UIFontDescriptor.preferredFontDescriptor(withTextStyle: style, + compatibleWith: UITraitCollection(preferredContentSizeCategory: .large)).pointSize + guard let font = UIFont(name: weight.face(boldText: boldText), size: base) else { + preconditionFailure("Missing registered Space Works font") + } + return UIFontMetrics(forTextStyle: style).scaledFont(for: font, compatibleWith: traits) + } +} + +private struct BrandFont: ViewModifier { + var style: Font.TextStyle + var weight: BrandTypography.Weight + @Environment(\.legibilityWeight) private var legibilityWeight + func body(content: Content) -> some View { + content.font(BrandTypography.font(style, weight: weight, boldText: legibilityWeight == .bold)) + } +} + +extension View { + func brandFont(_ style: Font.TextStyle, weight: BrandTypography.Weight? = nil) -> some View { + modifier(BrandFont(style: style, weight: weight ?? (style == .headline ? .semibold : .regular))) + } +} diff --git a/SpaceWorks/DemoAccountLandingView.swift b/SpaceWorks/DemoAccountLandingView.swift index a086aeb..307abf2 100644 --- a/SpaceWorks/DemoAccountLandingView.swift +++ b/SpaceWorks/DemoAccountLandingView.swift @@ -11,10 +11,10 @@ struct DemoAccountLandingView: View { ScrollView { VStack(alignment: .leading, spacing: 24) { BrandWordmark() - Text("Choose a makerspace").font(.largeTitle.bold()) + Text("Choose a makerspace").brandFont(.largeTitle, weight: .bold) Text("You haven’t joined a makerspace yet.").foregroundStyle(Color.swMuted) SWCard { - Text(session.name).font(.title2.bold()) + Text(session.name).brandFont(.title2, weight: .bold) NavigationLink("Account and security") { AccountView(state: account, privacyURL: Brand.privacyURL.flatMap(URL.init(string:)), supportURL: Brand.supportURL.flatMap(URL.init(string:)), onDemoDelete: session.reset) }.frame(minHeight: 48).accessibilityIdentifier("account-entry") diff --git a/SpaceWorks/DemoDeletion.swift b/SpaceWorks/DemoDeletion.swift index aa56029..e18a9df 100644 --- a/SpaceWorks/DemoDeletion.swift +++ b/SpaceWorks/DemoDeletion.swift @@ -87,9 +87,9 @@ struct DemoDeletionView: View { } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text("Delete account").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text("Delete account").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) Label("Demo only · no account will be deleted", systemImage: "info.circle").foregroundStyle(Color.swMuted) - Text("This deletes your whole Space Works account on this server, across every makerspace.").font(.headline) + Text("This deletes your whole Space Works account on this server, across every makerspace.").brandFont(.headline) Text("Payment and loan history is kept anonymised for legal and accounting records. Leaving one makerspace is a separate action.").foregroundStyle(Color.swMuted) if let error = state.error { SWError(message: error) } if state.stage == .explanation || state.stage == .blockers { @@ -101,18 +101,18 @@ struct DemoDeletionView: View { switch state.stage { case .explanation: Text("Review every makerspace's obligations, verify your identity, then confirm. You can leave at any time before confirmation.").foregroundStyle(Color.swMuted) case .blockers: - Text("Resolve these before deleting").font(.title2.bold()) + Text("Resolve these before deleting").brandFont(.title2, weight: .bold) ForEach(state.blockers) { blocker in SWCard { - Text(blocker.makerspace).font(.headline) + Text(blocker.makerspace).brandFont(.headline) Text(blocker.detail) - Text("\(blocker.count) outstanding").font(.subheadline).foregroundStyle(Color.swMuted) + Text("\(blocker.count) outstanding").brandFont(.subheadline).foregroundStyle(Color.swMuted) if let onFix { Button("Open \(blocker.destination.rawValue)") { onFix(blocker) }.frame(minHeight: 48) } else { NavigationLink("Open \(blocker.destination.rawValue)") { DemoDeletionResolutionView(blocker: blocker) }.frame(minHeight: 48) } } } case .reauthenticate: - Text("Verify your identity").font(.title2.bold()) + Text("Verify your identity").brandFont(.title2, weight: .bold) Picker("Verification method", selection: $state.method) { ForEach(DemoDeletionState.Method.allCases, id: \.self) { Text(LocalizedStringKey($0.rawValue)) } } .onChange(of: state.method) { _, _ in state.credential = "" } if state.method == .password { @@ -125,12 +125,12 @@ struct DemoDeletionView: View { } Button("Verify identity") { state.authenticate() }.buttonStyle(SWPrimary()).accessibilityIdentifier("deletion-verify") case .confirm: - Text("Confirm deletion").font(.title2.bold()) + Text("Confirm deletion").brandFont(.title2, weight: .bold) Text("Type DELETE to clear this demo account. This preview only resets in-memory sample data.").foregroundStyle(Color.swMuted) TextField("Type DELETE", text: $state.confirmation).textInputAutocapitalization(.characters).autocorrectionDisabled().focused($fieldFocused) .padding(16).background(Color.swPanel, in: RoundedRectangle(cornerRadius: 10)).overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)).accessibilityIdentifier("deletion-confirmation") Button("Delete demo account", role: .destructive) { state.deletePreviewAccount() }.buttonStyle(SWPrimary()).disabled(!state.canConfirm).accessibilityIdentifier("deletion-submit") - case .deleted: Label("Demo account cleared", systemImage: "checkmark.circle").font(.title2.bold()) + case .deleted: Label("Demo account cleared", systemImage: "checkmark.circle").brandFont(.title2, weight: .bold) } }.padding(24).frame(maxWidth: 680).frame(maxWidth: .infinity) }.background(Color.swBg).foregroundStyle(Color.swInk).tint(Color.swAccentInk) .navigationTitle("Delete account").navigationBarTitleDisplayMode(.inline).onDisappear { state.credential = ""; state.confirmation = "" } @@ -142,7 +142,7 @@ private struct DemoDeletionResolutionView: View { let blocker: DemoDeletionState.Blocker var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text(LocalizedStringKey(blocker.destination.rawValue)).font(.largeTitle.bold()); Text(blocker.makerspace).font(.headline) + Text(LocalizedStringKey(blocker.destination.rawValue)).brandFont(.largeTitle, weight: .bold); Text(blocker.makerspace).brandFont(.headline) Text(blocker.detail); Text("This preview does not resolve real obligations. Open the matching makerspace task from your account to continue.").foregroundStyle(Color.swMuted) }.padding(24).frame(maxWidth: 680).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle(LocalizedStringKey(blocker.destination.rawValue)) } diff --git a/SpaceWorks/DemoMode.swift b/SpaceWorks/DemoMode.swift index a589ed7..526dd28 100644 --- a/SpaceWorks/DemoMode.swift +++ b/SpaceWorks/DemoMode.swift @@ -101,7 +101,7 @@ struct DemoRoot: View { var body: some View { VStack(spacing: 0) { HStack { - Text("Demo mode · sample data").font(.caption.bold()) + Text("Demo mode · sample data").brandFont(.caption, weight: .bold) Spacer() Button { session.reset(); exit() } label: { Label("Exit demo", systemImage: "xmark").labelStyle(.iconOnly) @@ -152,7 +152,7 @@ struct DemoAuthView: View { SWCard { switch session.stage { case .welcome: - Text("Make yourself at home.").font(.largeTitle.bold()) + Text("Make yourself at home.").brandFont(.largeTitle, weight: .bold) Text("Explore sample makerspaces and complete app flows. Nothing is sent to a server.").foregroundStyle(Color.swMuted) Picker("Demo role", selection: $session.role) { ForEach(DemoSession.Role.allCases, id: \.self) { Text(LocalizedStringKey($0.rawValue)) } } .pickerStyle(.segmented).accessibilityIdentifier("demo-role") @@ -162,7 +162,7 @@ struct DemoAuthView: View { Picker("App data preview", selection: $session.memberScenario) { ForEach(DemoMemberRepository.Scenario.allCases, id: \.self) { Text(LocalizedStringKey($0.rawValue)) } } Button("Continue as \(session.role.rawValue.lowercased())") { session.stage = .signIn }.buttonStyle(SWPrimary()) case .signIn: - Text("Welcome back.").font(.largeTitle.bold()) + Text("Welcome back.").brandFont(.largeTitle, weight: .bold) Text("Your makerspace, in your pocket.").foregroundStyle(Color.swMuted) GeometryReader { geometry in GoogleProviderButton(wide: geometry.size.width >= 312) { provider = .google } @@ -173,14 +173,14 @@ struct DemoAuthView: View { .frame(maxWidth: .infinity, minHeight: 48) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)) } - Text("or with your email").font(.caption).foregroundStyle(Color.swMuted) - Text("Email or username").font(.headline) + Text("or with your email").brandFont(.caption).foregroundStyle(Color.swMuted) + Text("Email or username").brandFont(.headline) TextField("Email or username", text: $session.username, prompt: Text("Email or username").foregroundColor(Color.swMuted)).textInputAutocapitalization(.never) .autocorrectionDisabled().submitLabel(.next).onSubmit { passwordFocused = true } .demoField().accessibilityIdentifier("demo-username") PasswordControl(title: String(localized: "Password"), text: $session.password, identifier: "demo-password", focus: $passwordFocused) - Text("Use any email and password in this demo.").font(.caption).foregroundStyle(Color.swMuted) - Text("Use unverified to preview email confirmation.").font(.caption).foregroundStyle(Color.swMuted) + Text("Use any email and password in this demo.").brandFont(.caption).foregroundStyle(Color.swMuted) + Text("Use unverified to preview email confirmation.").brandFont(.caption).foregroundStyle(Color.swMuted) if let error = session.error { SWError(message: error) } TimelineView(.periodic(from: .now, by: 1)) { context in Button("Sign in") { session.signIn() }.buttonStyle(SWPrimary()).accessibilityIdentifier("demo-sign-in") @@ -190,37 +190,37 @@ struct DemoAuthView: View { Button("Create account") { accountForm = demoAccountForm(.signUp) }.frame(minHeight: 48) Button("Back") { session.reset() }.frame(minHeight: 48) case .mfa: - Text(session.scenario == .enrollment ? "Set up two-step sign-in" : "Verify your sign-in").font(.largeTitle.bold()) + Text(session.scenario == .enrollment ? "Set up two-step sign-in" : "Verify your sign-in").brandFont(.largeTitle, weight: .bold) if session.scenario == .enrollment { QRCodeView(value: "otpauth://totp/SpaceWorks:demo?secret=JBSWY3DPEHPK3PXP&issuer=SpaceWorks") - Text("Can't scan? Enter this key").font(.headline) + Text("Can't scan? Enter this key").brandFont(.headline) Text(verbatim: "JBSWY3DPEHPK3PXP").font(.body.monospaced()).textSelection(.enabled) } - Text("Authenticator or recovery code").font(.headline) + Text("Authenticator or recovery code").brandFont(.headline) TextField("Authenticator or recovery code", text: $session.code).demoField() .textContentType(.oneTimeCode).accessibilityIdentifier("demo-mfa-code") - Text("Use 123456 or sample-recovery in this demo.").font(.caption).foregroundStyle(Color.swMuted) + Text("Use 123456 or sample-recovery in this demo.").brandFont(.caption).foregroundStyle(Color.swMuted) if let error = session.error { SWError(message: error) } Button("Verify") { session.verify() }.buttonStyle(SWPrimary()) Button("Back to sign in") { session.stage = .signIn; session.error = nil }.frame(minHeight: 48) case .recovery: - Text("Save your recovery codes").font(.largeTitle.bold()) + Text("Save your recovery codes").brandFont(.largeTitle, weight: .bold) Text("Sample codes for this demo. Real codes appear once after enrollment.").foregroundStyle(Color.swMuted) Text(verbatim: "sample-recovery\nsample-backup").font(.body.monospaced()).textSelection(.enabled) Button("I saved my codes. Continue") { if session.securityEnrollment { session.securityEnded(codes: [], message: "Two-step sign-in was set up. Sign in again to continue.") } else { session.stage = session.membershipStage } }.buttonStyle(SWPrimary()) case .makerspace: - Text("Choose a makerspace").font(.largeTitle.bold()) + Text("Choose a makerspace").brandFont(.largeTitle, weight: .bold) ForEach(session.spaces.indices.filter { session.availableSpaces.contains($0) }, id: \.self) { index in Button { session.selectedSpace = index; session.stage = .shell } label: { Label(session.spaces[index], systemImage: "building.2").frame(maxWidth: .infinity, minHeight: 52, alignment: .leading) }.buttonStyle(.plain).accessibilityIdentifier("demo-space-\(index)") } case .webConsole: - Text("Use the web console").font(.largeTitle.bold()) + Text("Use the web console").brandFont(.largeTitle, weight: .bold) Text("Superadmin accounts cannot sign in on a phone or tablet.").foregroundStyle(Color.swMuted) Button("Back to demo") { session.reset() }.buttonStyle(SWPrimary()) case .updateRequired: - Text("Update required").font(.largeTitle.bold()) + Text("Update required").brandFont(.largeTitle, weight: .bold) Text("Update the app to keep using this server. This is a preview of the server's minimum-version gate.").foregroundStyle(Color.swMuted) Button("Back to demo") { session.reset() }.buttonStyle(SWPrimary()) case .shell, .noMakerspaces, .securityCompletion, .emailVerification: EmptyView() @@ -246,21 +246,21 @@ struct DemoProviderView: View { NavigationStack { ScrollView { SWCard { - Text("Continue with \(provider.rawValue)").font(.largeTitle.bold()) + Text("Continue with \(provider.rawValue)").brandFont(.largeTitle, weight: .bold) Text("Demo mode. No provider account is connected and no message is sent.").foregroundStyle(Color.swMuted) if provider == .phone { if state.step == .phone { Text("We'll send a verification code.").foregroundStyle(Color.swMuted) Picker("Country code", selection: $state.country) { Text("India +91").tag("+91"); Text("United States +1").tag("+1"); Text("United Kingdom +44").tag("+44") }.frame(minHeight: 48) - Text("Phone number").font(.headline) + Text("Phone number").brandFont(.headline) TextField("Phone number", text: $state.phone).textContentType(.telephoneNumber).keyboardType(.phonePad).demoField() Button("Send verification code") { state.sendCode() }.buttonStyle(SWPrimary()) .disabled(state.resendAfter > Date()) } else { - Text("Enter your verification code").font(.title2.bold()) + Text("Enter your verification code").brandFont(.title2, weight: .bold) Text(state.country + " " + state.phone).foregroundStyle(Color.swMuted) TextField("Verification code", text: $state.code).textContentType(.oneTimeCode).demoField() - Text("Use 123456 in this demo. No message was sent.").font(.caption).foregroundStyle(Color.swMuted) + Text("Use 123456 in this demo. No message was sent.").brandFont(.caption).foregroundStyle(Color.swMuted) TimelineView(.periodic(from: .now, by: 1)) { tick in Button(tick.date < state.resendAfter ? "Resend in \(max(1, Int(ceil(state.resendAfter.timeIntervalSince(tick.date)))))s" : "Send another code") { state.sendCode() } .frame(minHeight: 48).disabled(tick.date < state.resendAfter) @@ -268,7 +268,7 @@ struct DemoProviderView: View { Button("Change number") { state.changeNumber() }.frame(minHeight: 48) } } else { - Label(session.name, systemImage: "person.crop.circle").font(.title2) + Label(session.name, systemImage: "person.crop.circle").brandFont(.title2) } Picker("Preview result", selection: $state.outcome) { ForEach(DemoProviderState.Outcome.allCases, id: \.self) { Text(LocalizedStringKey($0.rawValue)) } } if let error = state.error { SWError(message: error) } @@ -419,10 +419,10 @@ struct DemoDestination: View { }) } else { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text("Welcome back, \(session.name).").font(.largeTitle.bold()) + Text("Welcome back, \(session.name).").brandFont(.largeTitle, weight: .bold) Text(session.spaces[session.selectedSpace]).foregroundStyle(Color.swMuted) SWCard { - Text(session.role.rawValue).font(.headline) + Text(session.role.rawValue).brandFont(.headline) if capabilities.busy { ProgressView("Loading account options…") } if let error = capabilities.error { SWError(message: error); Button("Retry account options") { Task { await capabilities.load() } }.frame(minHeight: 48) } if capabilities.selfService { NavigationLink("Edit profile", value: MemberRoute.contact).frame(minHeight: 48) } diff --git a/SpaceWorks/DemoNotificationsView.swift b/SpaceWorks/DemoNotificationsView.swift index 675587f..fadd475 100644 --- a/SpaceWorks/DemoNotificationsView.swift +++ b/SpaceWorks/DemoNotificationsView.swift @@ -17,11 +17,11 @@ struct DemoNotificationsView: View { ScrollView { VStack(alignment: .leading, spacing: 24) { SWCard { - Label("Notifications", systemImage: "bell.badge").font(.title2.bold()) + Label("Notifications", systemImage: "bell.badge").brandFont(.title2, weight: .bold) Text("Demo only. Changes stay in this preview. No notifications will be sent.").foregroundStyle(Color.swMuted) Toggle("Preview permission denied", isOn: $state.permissionDenied).frame(minHeight: 48) if state.permissionDenied { - Text("Notifications are off for this device.").font(.headline) + Text("Notifications are off for this device.").brandFont(.headline) Button("Open system settings") { state.feedback = String(localized: "In a connected app, this opens notification settings for your device.") }.frame(minHeight: 48) } else { Button(state.enabled ? "Turn off demo notifications" : "Enable notifications") { state.enabled.toggle(); state.feedback = state.enabled ? "Demo notifications are enabled." : "Demo notifications are off." } @@ -30,7 +30,7 @@ struct DemoNotificationsView: View { if let feedback = state.feedback { Text(feedback).foregroundStyle(Color.swMuted).accessibilityIdentifier("notification-preview-feedback") } } SWCard { - Text("Preview an update").font(.title2.bold()) + Text("Preview an update").brandFont(.title2, weight: .bold) Text("Open sample updates from your makerspaces. Changes stay in this preview.").foregroundStyle(Color.swMuted) Button("Open inventory update") { route(.init(makerspaceID: makerspaceID, destination: .product(1))) }.frame(minHeight: 48).accessibilityIdentifier("demo-push-product") Button("Open update from another makerspace") { route(.init(makerspaceID: makerspaceID == 7 ? 8 : 7, destination: .inbox)) }.frame(minHeight: 48).accessibilityIdentifier("demo-push-other-space") diff --git a/SpaceWorks/EventViews.swift b/SpaceWorks/EventViews.swift index 0f6841a..44de9c6 100644 --- a/SpaceWorks/EventViews.swift +++ b/SpaceWorks/EventViews.swift @@ -15,22 +15,22 @@ struct EventDetailView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - if isDemo { Text("Demo mode · no real event registration").font(.caption.bold()).foregroundStyle(Color.swMuted) } - Text(event.title).font(.largeTitle.bold()) + if isDemo { Text("Demo mode · no real event registration").brandFont(.caption, weight: .bold).foregroundStyle(Color.swMuted) } + Text(event.title).brandFont(.largeTitle, weight: .bold) Text(event.starts_at.formatted(date: .abbreviated, time: .shortened) + " · " + TimeZone.current.identifier).foregroundStyle(Color.swMuted) Label(event.location, systemImage: "mappin.and.ellipse") - SWCard { Text("About the event").font(.title2.bold()); Text(event.description) } + SWCard { Text("About the event").brandFont(.title2, weight: .bold); Text(event.description) } if let result = state.result { SWCard { - Label(resultTitle(result.status.value1.rawValue), systemImage: "ticket").font(.title2.bold()) + Label(resultTitle(result.status.value1.rawValue), systemImage: "ticket").brandFont(.title2, weight: .bold) Text("Find this registration in Activity. Tickets appear only after registration is confirmed.").foregroundStyle(Color.swMuted) - Text("Payment and approval are confirmed by your makerspace. This receipt does not confirm a payment.").font(.caption).foregroundStyle(Color.swMuted) + Text("Payment and approval are confirmed by your makerspace. This receipt does not confirm a payment.").brandFont(.caption).foregroundStyle(Color.swMuted) Button("View registrations", action: viewActivity).buttonStyle(SWPrimary()) } - } else if !event.registration_open { SWCard { Text("Registration is closed.").font(.headline) } } + } else if !event.registration_open { SWCard { Text("Registration is closed.").brandFont(.headline) } } else { SWCard { - Text("Register for this event").font(.title2.bold()) + Text("Register for this event").brandFont(.title2, weight: .bold) Text("Event fee: \(event.currency) \(event.payment_amount)").foregroundStyle(Color.swMuted) if !event.ticket_types.isEmpty { Picker("Ticket type", selection: $state.ticket) { @@ -61,7 +61,7 @@ struct EventDetailView: View { } private func input(_ label: String, value: Binding) -> some View { VStack(alignment: .leading, spacing: 8) { - Text(label).font(.headline) + Text(label).brandFont(.headline) TextField(label, text: value).padding(14).background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)) } @@ -85,17 +85,17 @@ struct MemberRegistrationView: View { var body: some View { ScrollView { SWCard { - if isDemo { Text("Demo mode · sample event ticket").font(.caption.bold()).foregroundStyle(Color.swMuted) } - Text(registration.event_title).font(.largeTitle.bold()) + if isDemo { Text("Demo mode · sample event ticket").brandFont(.caption, weight: .bold).foregroundStyle(Color.swMuted) } + Text(registration.event_title).brandFont(.largeTitle, weight: .bold) Text(registration.starts_at.formatted(date: .abbreviated, time: .shortened)).foregroundStyle(Color.swMuted) - Text(state.cancelled ? "Cancelled" : NativeStatusCopy.label(registration.status)).font(.headline) + Text(state.cancelled ? "Cancelled" : NativeStatusCopy.label(registration.status)).brandFont(.headline) if let position = registration.waitlist_position, !state.cancelled { Text("Waitlist position: \(position)").foregroundStyle(Color.swMuted) } if state.cancelled { Text("Your registration was cancelled. Cancelling doesn't automatically issue a refund.").foregroundStyle(Color.swMuted) } else if registration.status == "registered" { if state.busy { ProgressView("Loading your ticket…") } else if state.image != nil && isDemo { QRCodeView(value: "spaceworks-demo-event-ticket", label: "Simulated event ticket QR code") - Text("Simulated QR · demo only").font(.caption).foregroundStyle(Color.swMuted) + Text("Simulated QR · demo only").brandFont(.caption).foregroundStyle(Color.swMuted) } else if let image = state.image { Image(uiImage: image).resizable().interpolation(.none).scaledToFit().frame(maxWidth: 340).accessibilityLabel("Event check-in QR code") } } else { Text("A ticket is available only after registration is confirmed.").foregroundStyle(Color.swMuted) } if let error = state.error { @@ -103,7 +103,7 @@ struct MemberRegistrationView: View { else { SWError(message: error) } } if !state.cancelled && registration.can_cancel { - Text("Cancellation cutoff: \(registration.cancellation_cutoff_at.formatted(date: .abbreviated, time: .shortened))").font(.caption).foregroundStyle(Color.swMuted) + Text("Cancellation cutoff: \(registration.cancellation_cutoff_at.formatted(date: .abbreviated, time: .shortened))").brandFont(.caption).foregroundStyle(Color.swMuted) TimelineView(.periodic(from: .now, by: 1)) { tick in Button("Cancel registration", role: .destructive) { confirmCancel = true }.frame(minHeight: 48) .disabled(state.busy || (state.retryAfter ?? .distantPast) > tick.date) diff --git a/SpaceWorks/IntroductionView.swift b/SpaceWorks/IntroductionView.swift index b8e6f49..bda3398 100644 --- a/SpaceWorks/IntroductionView.swift +++ b/SpaceWorks/IntroductionView.swift @@ -43,8 +43,8 @@ struct IntroductionView: View { } }.padding(20).background(Color.swSuccessSoft, in: RoundedRectangle(cornerRadius: 24)).accessibilityHidden(true) } - Text(headings[page]).font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) - Text(details[page]).font(.title3).foregroundStyle(Color.swMuted) + Text(headings[page]).brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) + Text(details[page]).brandFont(.title3).foregroundStyle(Color.swMuted) }.id(page).transition(.opacity) Spacer(minLength: 16) Button(page == 2 ? "Get started" : "Next") { diff --git a/SpaceWorks/MemberActionViews.swift b/SpaceWorks/MemberActionViews.swift index 8d3cc99..241babd 100644 --- a/SpaceWorks/MemberActionViews.swift +++ b/SpaceWorks/MemberActionViews.swift @@ -10,34 +10,34 @@ struct RequestCartView: View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 24) { - if isDemo { Text("Demo mode · no real request is submitted").font(.caption.bold()).foregroundStyle(Color.swMuted) } + if isDemo { Text("Demo mode · no real request is submitted").brandFont(.caption, weight: .bold).foregroundStyle(Color.swMuted) } if let receipt = state.receipt { SWCard { - Label("Request received", systemImage: "checkmark.circle").font(.largeTitle.bold()) - Text(NativeStatusCopy.label(receipt.status)).font(.title2) + Label("Request received", systemImage: "checkmark.circle").brandFont(.largeTitle, weight: .bold) + Text(NativeStatusCopy.label(receipt.status)).brandFont(.title2) Text("Your makerspace will review your request and confirm collection and due dates.").foregroundStyle(Color.swMuted) - Text("Request reference").font(.headline) + Text("Request reference").brandFont(.headline) Text(receipt.public_token).font(.body.monospaced()).textSelection(.enabled) Button("Done") { dismiss() }.buttonStyle(SWPrimary()) } } else { - Text("Review your request").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text("Review your request").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) Text("Check the items and quantities. Your makerspace confirms availability and borrowing terms.").foregroundStyle(Color.swMuted) if state.lines.isEmpty { SWCard { Text("Your request is empty."); Text("Add an item from inventory to get started.").foregroundStyle(Color.swMuted) } } ForEach(state.lines) { line in SWCard { - Text(line.product.name).font(.headline) + Text(line.product.name).brandFont(.headline) Stepper("Quantity: \(line.quantity)", value: Binding(get: { state.lines.first(where: { $0.id == line.id })?.quantity ?? 1 }, set: { state.quantity($0, for: line.id) }), in: 1...99) .frame(minHeight: 48) Button("Remove item", role: .destructive) { state.quantity(0, for: line.id) }.frame(minHeight: 48) } } SWCard { - Text("What are you making? (optional)").font(.headline) + Text("What are you making? (optional)").brandFont(.headline) TextField("Tell your makerspace about your project", text: $state.purpose, axis: .vertical) .lineLimit(3...6).padding(14).background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)).accessibilityIdentifier("request-purpose") - Text("Up to 500 characters. Don't include sensitive information.").font(.caption).foregroundStyle(Color.swMuted) + Text("Up to 500 characters. Don't include sensitive information.").brandFont(.caption).foregroundStyle(Color.swMuted) if let error = state.error { SWError(message: error) } if state.retryAfter != nil { TimelineView(.periodic(from: .now, by: 1)) { tick in @@ -76,11 +76,11 @@ struct RenewLoanView: View { NavigationStack { ScrollView { SWCard { - if isDemo { Text("Demo mode · no real loan is changed").font(.caption.bold()).foregroundStyle(Color.swMuted) } - Text(result == nil ? "Renew this loan?" : "Loan renewed").font(.largeTitle.bold()) - Text(loan.label).font(.title2.bold()) + if isDemo { Text("Demo mode · no real loan is changed").brandFont(.caption, weight: .bold).foregroundStyle(Color.swMuted) } + Text(result == nil ? "Renew this loan?" : "Loan renewed").brandFont(.largeTitle, weight: .bold) + Text(loan.label).brandFont(.title2, weight: .bold) if let result { - Text("New due date").font(.headline) + Text("New due date").brandFont(.headline) Text(result.return_due_at.formatted(date: .abbreviated, time: .shortened)) Button("Done") { dismiss() }.buttonStyle(SWPrimary()) } else { diff --git a/SpaceWorks/MemberBookingsView.swift b/SpaceWorks/MemberBookingsView.swift index b760672..454c2fd 100644 --- a/SpaceWorks/MemberBookingsView.swift +++ b/SpaceWorks/MemberBookingsView.swift @@ -7,7 +7,7 @@ struct MemberBookingsView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text("Bookings").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text("Bookings").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) NavigationLink("Find a space", value: MemberRoute.spaces).frame(minHeight: 48) LoadFeedback(load: state.bookingsLoad, lastUpdated: state.updated["bookings"], retryAfter: state.deadline("bookings")) { await state.loadBookings() } if state.bookingsLoad == .ready && state.bookings.isEmpty { @@ -15,7 +15,7 @@ struct MemberBookingsView: View { } ForEach(state.bookings, id: \.public_token) { row in SWCard { - NavigationLink(row.space_name, value: MemberRoute.booking(row.public_token)).font(.headline).frame(minHeight: 48) + NavigationLink(row.space_name, value: MemberRoute.booking(row.public_token)).brandFont(.headline).frame(minHeight: 48) Text(guideBookingDate(row.starts_at, timezone: row.timezone)) Text(NativeStatusCopy.label(row.status.value1.rawValue)).foregroundStyle(Color.swMuted) } diff --git a/SpaceWorks/MemberCustomForm.swift b/SpaceWorks/MemberCustomForm.swift index ccedfc4..59a325a 100644 --- a/SpaceWorks/MemberCustomForm.swift +++ b/SpaceWorks/MemberCustomForm.swift @@ -80,7 +80,7 @@ struct MemberCustomFields: View { } else { ForEach(form.questions) { question in VStack(alignment: .leading, spacing: 8) { - Text(question.label + (question.required ? String(localized: " (required)") : "")).font(.headline) + Text(question.label + (question.required ? String(localized: " (required)") : "")).brandFont(.headline) field(question) if let error = form.errors[question.id] { Text(error).foregroundStyle(Color.swDanger) } } diff --git a/SpaceWorks/MemberRoutes.swift b/SpaceWorks/MemberRoutes.swift index a86d59f..4311bc0 100644 --- a/SpaceWorks/MemberRoutes.swift +++ b/SpaceWorks/MemberRoutes.swift @@ -67,7 +67,7 @@ struct MemberRouteView: View { ScrollView { SWCard { LoadFeedback(load: load, retry: retry) - if load == .ready { Text(title).font(.headline); Text("Refresh the list and choose an available item.").foregroundStyle(Color.swMuted) } + if load == .ready { Text(title).brandFont(.headline); Text("Refresh the list and choose an available item.").foregroundStyle(Color.swMuted) } }.padding(24).frame(maxWidth: 640).frame(maxWidth: .infinity) }.background(Color.swBg) } diff --git a/SpaceWorks/MemberSettingsViews.swift b/SpaceWorks/MemberSettingsViews.swift index dcc4e07..8c080ef 100644 --- a/SpaceWorks/MemberSettingsViews.swift +++ b/SpaceWorks/MemberSettingsViews.swift @@ -14,15 +14,15 @@ struct ProfileEditorView: View { var body: some View { ScrollView { SWCard { - if isDemo { Text("Demo mode · sample profile").font(.caption.bold()).foregroundStyle(Color.swMuted) } - Text("Edit profile").font(.largeTitle.bold()) + if isDemo { Text("Demo mode · sample profile").brandFont(.caption, weight: .bold).foregroundStyle(Color.swMuted) } + Text("Edit profile").brandFont(.largeTitle, weight: .bold) Text("Your contact details in this makerspace. Changing a phone number here doesn't verify it for sign-in.").foregroundStyle(Color.swMuted) if state.loaded { field(String(localized: "Display name"), value: $state.displayName, identifier: "profile-display-name", content: .name) field(String(localized: "First name"), value: $state.firstName, identifier: "profile-first-name", content: .givenName) field(String(localized: "Last name"), value: $state.lastName, identifier: "profile-last-name", content: .familyName) field(String(localized: "Phone number"), value: $state.phone, identifier: "profile-phone", content: .telephoneNumber) - if state.dirty { Text("Unsaved changes").font(.caption).foregroundStyle(Color.swMuted) } + if state.dirty { Text("Unsaved changes").brandFont(.caption).foregroundStyle(Color.swMuted) } else if let notice = state.notice { Label(notice, systemImage: "checkmark.circle").foregroundStyle(Color.swAccentInk) } if let error = state.error { SWError(message: error) } TimelineView(.periodic(from: .now, by: 1)) { tick in @@ -48,7 +48,7 @@ struct ProfileEditorView: View { } private func field(_ label: String, value: Binding, identifier: String, content: UITextContentType) -> some View { VStack(alignment: .leading, spacing: 8) { - Text(label).font(.headline) + Text(label).brandFont(.headline) TextField(label, text: value).textContentType(content).autocorrectionDisabled() .focused($focusedField, equals: identifier).submitLabel(.done).onSubmit { focusedField = nil } .keyboardType(content == .telephoneNumber ? .phonePad : .default) @@ -66,9 +66,9 @@ struct CommunicationPreferencesView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text("Communication preferences").font(.largeTitle.bold()) + Text("Communication preferences").brandFont(.largeTitle, weight: .bold) Text("Choose optional updates for this makerspace. Required safety messages stay on. Device notification permission is a separate setting.").foregroundStyle(Color.swMuted) - if isDemo { Text("Demo mode · no real preferences are changed").font(.caption.bold()).foregroundStyle(Color.swMuted) } + if isDemo { Text("Demo mode · no real preferences are changed").brandFont(.caption, weight: .bold).foregroundStyle(Color.swMuted) } if let error = state.error { SWError(message: error) } if !state.loaded { if state.error != nil { Button("Try again") { Task { await state.load() } }.frame(minHeight: 48) } @@ -76,7 +76,7 @@ struct CommunicationPreferencesView: View { } else if state.cells.isEmpty { SWCard { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } } ForEach(Array(state.cells.enumerated()), id: \.offset) { _, cell in SWCard { - Text(NativeStatusCopy.label(cell.feature.rawValue)).font(.headline) + Text(NativeStatusCopy.label(cell.feature.rawValue)).brandFont(.headline) TimelineView(.periodic(from: .now, by: 1)) { tick in Toggle(channel(cell.channel.rawValue), isOn: Binding(get: { cell.enabled }, set: { value in Task { await state.set(cell, enabled: value) } })) .accessibilityIdentifier("preference-\(cell.feature.rawValue)-\(cell.channel.rawValue)") @@ -86,9 +86,9 @@ struct CommunicationPreferencesView: View { .frame(minHeight: 48).disabled(state.busy || !cell.available || !cell.enabled || (state.retryAfter ?? .distantPast) > tick.date) } } - if !cell.available { Text("Unavailable in this makerspace.").font(.caption).foregroundStyle(Color.swMuted) } + if !cell.available { Text("Unavailable in this makerspace.").brandFont(.caption).foregroundStyle(Color.swMuted) } if !cell.mandatory_events.isEmpty { - Text("Always-on safety messages: \(cell.mandatory_events.map(NativeStatusCopy.label).joined(separator: ", "))").font(.caption).foregroundStyle(Color.swMuted) + Text("Always-on safety messages: \(cell.mandatory_events.map(NativeStatusCopy.label).joined(separator: ", "))").brandFont(.caption).foregroundStyle(Color.swMuted) } } } diff --git a/SpaceWorks/MemberViews.swift b/SpaceWorks/MemberViews.swift index 74b2642..95ffc1c 100644 --- a/SpaceWorks/MemberViews.swift +++ b/SpaceWorks/MemberViews.swift @@ -26,11 +26,16 @@ struct MemberContent: View { ScrollView { VStack(alignment: .leading, spacing: 24) { if tab == .home { - Text("Welcome back, \(name).").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text("Welcome back, \(name).").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) Text("Your makerspace, at a glance.").foregroundStyle(Color.swMuted) if state.showGuidance { SWCard { - HStack { Text("Start here").font(.headline); Spacer(); Button("Dismiss") { state.dismissGuidance() }.frame(minHeight: 48).accessibilityIdentifier("guidance-dismiss") } + if textSize.isAccessibilitySize { + Text("Start here").brandFont(.headline) + Button("Dismiss") { state.dismissGuidance() }.frame(minHeight: 48).accessibilityIdentifier("guidance-dismiss") + } else { + HStack { Text("Start here").brandFont(.headline); Spacer(); Button("Dismiss") { state.dismissGuidance() }.frame(minHeight: 48).accessibilityIdentifier("guidance-dismiss") } + } Text("Find what you need, then request it from your makerspace.").foregroundStyle(Color.swMuted) Button("Browse inventory") { selectTab(.inventory) }.buttonStyle(SWPrimary()) if guidanceBookingsEnabled { Button("View bookings") { if let navigation { navigation.openBookings() } else { selectTab(.activity) } }.frame(minHeight: 48) } @@ -49,7 +54,7 @@ struct MemberContent: View { if tab == .home { dues SWCard { - Label("Membership card", systemImage: "person.text.rectangle").font(.title2.bold()) + Label("Membership card", systemImage: "person.text.rectangle").brandFont(.title2, weight: .bold) Text("Keep your membership details close. Ask the front desk for help if your card isn't available.").foregroundStyle(Color.swMuted) Button("View membership card") { showCard = true }.frame(minHeight: 48) } @@ -75,19 +80,19 @@ struct MemberContent: View { } private var loans: some View { SWCard { - Label("Active loans", systemImage: "shippingbox").font(.title2.bold()) + Label("Active loans", systemImage: "shippingbox").brandFont(.title2, weight: .bold) LoadFeedback(load: state.activityLoad, lastUpdated: state.updated["activity"], retryAfter: state.deadline("activity")) { await state.loadActivity() } if let activity = state.activity { if activity.active_hardware_loans.isEmpty { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } ForEach(activity.active_hardware_loans, id: \.request_id) { loan in VStack(alignment: .leading, spacing: 8) { - Text(loan.label).font(.headline) + Text(loan.label).brandFont(.headline) if let due = loan.due_at { Label("Due \(due.formatted(date: .abbreviated, time: .shortened))", systemImage: "calendar") } if loan.overdue { Label("Overdue", systemImage: "exclamationmark.circle").foregroundStyle(Color.swDanger) } else { Text("On loan").foregroundStyle(Color.swMuted) } if actions != nil { Button("Renew loan") { renewal = loan }.frame(minHeight: 48).disabled(!loan.can_renew) - if !loan.can_renew { Text(loan.renewal_refusal ?? String(localized: "Renewal isn't available for this loan. Ask your makerspace for help.")).font(.caption).foregroundStyle(Color.swMuted) } + if !loan.can_renew { Text(loan.renewal_refusal ?? String(localized: "Renewal isn't available for this loan. Ask your makerspace for help.")).brandFont(.caption).foregroundStyle(Color.swMuted) } } }.padding(.vertical, 8) } @@ -96,12 +101,12 @@ struct MemberContent: View { } private var requests: some View { SWCard { - Label("Requests", systemImage: "list.clipboard").font(.title2.bold()) + Label("Requests", systemImage: "list.clipboard").brandFont(.title2, weight: .bold) LoadFeedback(load: state.activityLoad, retryAfter: state.deadline("activity")) { await state.loadActivity() } if let rows = state.activity?.request_history, !rows.isEmpty { ForEach(Array(rows.enumerated()), id: \.offset) { _, row in VStack(alignment: .leading, spacing: 8) { - Text(NativeStatusCopy.label(row.status)).font(.headline) + Text(NativeStatusCopy.label(row.status)).brandFont(.headline) Text("\(row.item_count) items · \(row.created_at.formatted(date: .abbreviated, time: .omitted))").foregroundStyle(Color.swMuted) } } @@ -110,14 +115,14 @@ struct MemberContent: View { } private var bookings: some View { SWCard { - Label("Upcoming bookings", systemImage: "calendar").font(.title2.bold()) + Label("Upcoming bookings", systemImage: "calendar").brandFont(.title2, weight: .bold) if actions != nil { NavigationLink("Find a space", value: MemberRoute.spaces).frame(minHeight: 48) } LoadFeedback(load: state.bookingsLoad, lastUpdated: state.updated["bookings"], retryAfter: state.deadline("bookings")) { await state.loadBookings() } if state.bookingsLoad == .ready && state.bookings.isEmpty { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } ForEach(state.bookings, id: \.public_token) { booking in VStack(alignment: .leading, spacing: 8) { - if actions != nil { NavigationLink(booking.space_name, value: MemberRoute.booking(booking.public_token)).font(.headline).frame(minHeight: 48) } - else { Text(booking.space_name).font(.headline) } + if actions != nil { NavigationLink(booking.space_name, value: MemberRoute.booking(booking.public_token)).brandFont(.headline).frame(minHeight: 48) } + else { Text(booking.space_name).brandFont(.headline) } Text(memberDate(booking.starts_at, timezone: booking.timezone)) Text(NativeStatusCopy.label(booking.status.value1.rawValue)).foregroundStyle(Color.swMuted) }.padding(.vertical, 8) @@ -127,13 +132,13 @@ struct MemberContent: View { } private var events: some View { SWCard { - Label("Events", systemImage: "ticket").font(.title2.bold()) + Label("Events", systemImage: "ticket").brandFont(.title2, weight: .bold) LoadFeedback(load: state.eventsLoad, lastUpdated: state.updated["events"], retryAfter: state.deadline("events")) { await state.loadEvents() } if state.eventsLoad == .ready && state.events.isEmpty { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } ForEach(state.events, id: \.public_token) { event in VStack(alignment: .leading, spacing: 8) { - if actions != nil { NavigationLink(event.title, value: MemberRoute.event(event.public_token)).font(.headline).frame(minHeight: 48) } - else { Text(event.title).font(.headline) } + if actions != nil { NavigationLink(event.title, value: MemberRoute.event(event.public_token)).brandFont(.headline).frame(minHeight: 48) } + else { Text(event.title).brandFont(.headline) } Text(memberDate(event.starts_at, timezone: TimeZone.current.identifier)).foregroundStyle(Color.swMuted) }.padding(.vertical, 8) } @@ -141,12 +146,12 @@ struct MemberContent: View { } private var registrations: some View { SWCard { - Label("Event registrations", systemImage: "qrcode").font(.title2.bold()) + Label("Event registrations", systemImage: "qrcode").brandFont(.title2, weight: .bold) LoadFeedback(load: state.activityLoad, retryAfter: state.deadline("activity")) { await state.loadActivity() } if let rows = state.activity?.event_registrations, !rows.isEmpty { ForEach(rows, id: \.registration_id) { row in VStack(alignment: .leading, spacing: 8) { - NavigationLink(row.event_title, value: MemberRoute.registration(row.registration_id)).font(.headline).frame(minHeight: 48).accessibilityIdentifier("registration-\(row.registration_id)") + NavigationLink(row.event_title, value: MemberRoute.registration(row.registration_id)).brandFont(.headline).frame(minHeight: 48).accessibilityIdentifier("registration-\(row.registration_id)") Text(NativeStatusCopy.label(row.status)).foregroundStyle(Color.swMuted) Text(row.starts_at.formatted(date: .abbreviated, time: .shortened)).foregroundStyle(Color.swMuted) } @@ -156,17 +161,17 @@ struct MemberContent: View { } private var notices: some View { SWCard { - Label("Notices", systemImage: "megaphone").font(.title2.bold()) + Label("Notices", systemImage: "megaphone").brandFont(.title2, weight: .bold) if let rows = state.activity?.notices, !rows.isEmpty { ForEach(Array(rows.enumerated()), id: \.offset) { _, notice in - VStack(alignment: .leading, spacing: 8) { Text(notice.title).font(.headline); Text(notice.body).foregroundStyle(Color.swMuted) } + VStack(alignment: .leading, spacing: 8) { Text(notice.title).brandFont(.headline); Text(notice.body).foregroundStyle(Color.swMuted) } } } else if state.activityLoad == .ready { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } } } private var dues: some View { SWCard { - Label("Dues", systemImage: "creditcard").font(.title2.bold()) + Label("Dues", systemImage: "creditcard").brandFont(.title2, weight: .bold) if paymentsAvailable { NavigationLink("Payments", value: MemberRoute.payments).frame(minHeight: 48).accessibilityIdentifier("payments-home-entry") } if state.activityLoad == .loading || state.activityLoad == .idle { LoadFeedback(load: state.activityLoad, retryAfter: state.deadline("activity")) { await state.loadActivity() } @@ -175,7 +180,7 @@ struct MemberContent: View { let object = try? JSONSerialization.jsonObject(with: encoded) as? [String: Any], let amounts = object["outstanding_by_currency"] as? [String: String] { if amounts.isEmpty { Text("No dues outstanding.") } - ForEach(amounts.keys.sorted(), id: \.self) { currency in Text("\(currency) \(amounts[currency] ?? "")").font(.title2.bold()).monospacedDigit() } + ForEach(amounts.keys.sorted(), id: \.self) { currency in Text("\(currency) \(amounts[currency] ?? "")").brandFont(.title2, weight: .bold).monospacedDigit() } } else { Text("Dues are unavailable. Try again later.").foregroundStyle(Color.swMuted) } } } @@ -198,7 +203,7 @@ struct LoadFeedback: View { case .failed(let message): VStack(alignment: .leading) { SWError(message: message) - if let lastUpdated { Text("Last updated \(lastUpdated.formatted(date: .abbreviated, time: .shortened))").font(.caption).foregroundStyle(Color.swMuted) } + if let lastUpdated { Text("Last updated \(lastUpdated.formatted(date: .abbreviated, time: .shortened))").brandFont(.caption).foregroundStyle(Color.swMuted) } TimelineView(.periodic(from: .now, by: 1)) { tick in Button((retryAfter ?? .distantPast) > tick.date ? CooldownCopy.retryButton(until: retryAfter ?? tick.date, now: tick.date) : "Try again") { Task { await retry() } } .frame(minHeight: 48).disabled((retryAfter ?? .distantPast) > tick.date) @@ -258,7 +263,7 @@ struct InventoryList: View { LoadFeedback(load: state.inventoryLoad, lastUpdated: state.updated["inventory"], retryAfter: state.deadline("inventory")) { await state.loadInventory() } if state.inventoryLoad == .ready && state.products.isEmpty { SWCard { - Text(state.search.isEmpty && state.category == nil ? "No items yet" : "No items match these filters").font(.headline) + Text(state.search.isEmpty && state.category == nil ? "No items yet" : "No items match these filters").brandFont(.headline) if !state.search.isEmpty || state.category != nil { Button("Clear filters") { state.search = ""; state.category = nil; Task { await state.loadInventory() } }.frame(minHeight: 48) } @@ -282,7 +287,7 @@ struct InventoryList: View { SWCard { if textSize.isAccessibilitySize { VStack(alignment: .leading, spacing: 8) { - Text(verbatim: product.name).font(.headline).fixedSize(horizontal: false, vertical: true) + Text(verbatim: product.name).brandFont(.headline).fixedSize(horizontal: false, vertical: true) if let category = product.category_name { Text(verbatim: category).foregroundStyle(Color.swMuted).fixedSize(horizontal: false, vertical: true) } Text(availability(product)).foregroundStyle(Color.swMuted).fixedSize(horizontal: false, vertical: true) }.frame(maxWidth: .infinity, alignment: .leading) @@ -290,7 +295,7 @@ struct InventoryList: View { HStack(alignment: .top, spacing: 16) { Image(systemName: "shippingbox").font(.title).frame(width: 48, height: 48).background(Color.swSurface, in: RoundedRectangle(cornerRadius: 10)).accessibilityHidden(true) VStack(alignment: .leading, spacing: 8) { - Text(verbatim: product.name).font(.headline) + Text(verbatim: product.name).brandFont(.headline) if let category = product.category_name { Text(verbatim: category).foregroundStyle(Color.swMuted) } Text(availability(product)).foregroundStyle(Color.swMuted) } @@ -331,7 +336,7 @@ struct InventoryDetail: View { ScrollView { VStack(alignment: .leading, spacing: 24) { if let product = state.product { - Text(product.name).font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text(product.name).brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) Text(availability(product)).foregroundStyle(Color.swMuted) if let cart { Button("Add to request") { cart.add(product); added = cart.lines.contains(where: { $0.id == product.id }) }.buttonStyle(SWPrimary()).disabled(cart.busy) @@ -339,8 +344,8 @@ struct InventoryDetail: View { if let error = cart.error { SWError(message: error) } Button("Review your request") { reviewCart = true }.frame(minHeight: 48).accessibilityIdentifier("review-request") } - SWCard { Text("About this item").font(.title2.bold()); Text(product.description) } - SWCard { Text("Borrowing terms").font(.title2.bold()); Text(product.borrowing_terms.isEmpty ? "Ask your makerspace about borrowing this item." : product.borrowing_terms) } + SWCard { Text("About this item").brandFont(.title2, weight: .bold); Text(product.description) } + SWCard { Text("Borrowing terms").brandFont(.title2, weight: .bold); Text(product.borrowing_terms.isEmpty ? "Ask your makerspace about borrowing this item." : product.borrowing_terms) } } else if let error = state.error { SWCard { LoadFeedback(load: .failed(error), retryAfter: state.retryAfter) { await state.load() } } } else { ProgressView("Loading item…") } @@ -366,9 +371,9 @@ struct MemberInbox: View { if state.inboxLoad == .ready && state.messages.isEmpty { SWCard { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } } ForEach(state.messages, id: \.id) { message in SWCard { - HStack { Text(message.title).font(.headline); Spacer(); if message.read_at == nil { Label("Unread", systemImage: "circle.fill").font(.caption).foregroundStyle(Color.swAccentInk) } } + HStack { Text(message.title).brandFont(.headline); Spacer(); if message.read_at == nil { Label("Unread", systemImage: "circle.fill").brandFont(.caption).foregroundStyle(Color.swAccentInk) } } Text(message.summary).foregroundStyle(Color.swMuted) - Text(message.created_at.formatted(date: .abbreviated, time: .shortened)).font(.caption).foregroundStyle(Color.swMuted) + Text(message.created_at.formatted(date: .abbreviated, time: .shortened)).brandFont(.caption).foregroundStyle(Color.swMuted) if let openLink, !message.link.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Button("Open update") { openLink(message.link) }.frame(minHeight: 48).accessibilityIdentifier("open-inbox-update-\(message.id)") } @@ -389,11 +394,11 @@ struct MemberCardView: View { NavigationStack { ScrollView { SWCard { - if state.repository.isDemo { Text("Demo mode · sample data").font(.caption.bold()).foregroundStyle(Color.swMuted) } - Text("Membership card").font(.largeTitle.bold()) + if state.repository.isDemo { Text("Demo mode · sample data").brandFont(.caption, weight: .bold).foregroundStyle(Color.swMuted) } + Text("Membership card").brandFont(.largeTitle, weight: .bold) LoadFeedback(load: state.cardLoad, retryAfter: state.deadline("card")) { await state.loadCard() } if let card = state.card, card.is_active { - Text(card.printed_name).font(.title2.bold()) + Text(card.printed_name).brandFont(.title2, weight: .bold) Text("Card \(card.card_number)").monospacedDigit() if card.qr_active, let image = card.qr_image.flatMap(Self.qrImage) { Image(uiImage: image).interpolation(.none).resizable().scaledToFit().frame(maxWidth: 320).accessibilityLabel("Membership card QR code") diff --git a/SpaceWorks/NativeAppearance.swift b/SpaceWorks/NativeAppearance.swift index fad2c5b..1490384 100644 --- a/SpaceWorks/NativeAppearance.swift +++ b/SpaceWorks/NativeAppearance.swift @@ -1,6 +1,6 @@ import UIKit -/// Native bars use the same adaptive semantic assets as SwiftUI content. +/// Keep modern system navigation materials; older bars use adaptive semantic assets. @MainActor enum NativeAppearance { static func color(_ role: String) -> UIColor { @@ -40,6 +40,13 @@ enum NativeAppearance { static func apply() { let navigation = UINavigationBar.appearance() + // UIKit supplies Liquid Glass and scroll-edge treatment on iOS 26. + // Custom appearance backgrounds can cover those system materials (Apple TN3106). + if #available(iOS 26, *) { + navigation.tintColor = color("accent-ink") + UITabBar.appearance().tintColor = color("accent-ink") + return + } let navigationStyle = self.navigation() navigation.standardAppearance = navigationStyle navigation.scrollEdgeAppearance = navigationStyle diff --git a/SpaceWorks/NotificationSettingsView.swift b/SpaceWorks/NotificationSettingsView.swift index d031553..859330d 100644 --- a/SpaceWorks/NotificationSettingsView.swift +++ b/SpaceWorks/NotificationSettingsView.swift @@ -8,12 +8,12 @@ struct NotificationSettingsView: View { ScrollView { VStack(alignment: .leading, spacing: 24) { SWCard { - Label("Notifications", systemImage: "bell.badge").font(.title2.bold()) + Label("Notifications", systemImage: "bell.badge").brandFont(.title2, weight: .bold) Text("Keep up with requests, bookings, and important makerspace updates. Your inbox is always available.").foregroundStyle(Color.swMuted) if !state.registration.configured { Text("Notifications are not configured for this build. Your inbox is still available.").foregroundStyle(Color.swMuted) } else if state.permissionDenied { - Text("Notifications are off for this device.").font(.headline) + Text("Notifications are off for this device.").brandFont(.headline) Button("Open system settings") { state.openSettings() }.frame(minHeight: 48) } else { Text(state.registration.enabled ? "Notifications are enabled for this account." : "Enable notifications when you're ready.").foregroundStyle(Color.swMuted) diff --git a/SpaceWorks/PasswordEntry.swift b/SpaceWorks/PasswordEntry.swift index 84df8bf..66ca0b7 100644 --- a/SpaceWorks/PasswordEntry.swift +++ b/SpaceWorks/PasswordEntry.swift @@ -3,6 +3,8 @@ import UIKit /// One UIKit text input keeps autofill, selection and keyboard identity while visibility changes. struct PasswordEntry: UIViewRepresentable { + @Environment(\.legibilityWeight) private var legibilityWeight + @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Binding var text: String var visible = false var newPassword = false @@ -18,7 +20,7 @@ struct PasswordEntry: UIViewRepresentable { field.autocorrectionType = .no field.autocapitalizationType = .none field.adjustsFontForContentSizeCategory = true - field.font = .preferredFont(forTextStyle: .body) + field.font = BrandTypography.uiFont(traits: field.traitCollection, boldText: legibilityWeight == .bold) field.textColor = UIColor(named: "ink") field.tintColor = UIColor(named: "focus") field.returnKeyType = .done @@ -35,6 +37,9 @@ struct PasswordEntry: UIViewRepresentable { } func updateUIView(_ field: UITextField, context: Context) { context.coordinator.parent = self + let traits = UITraitCollection(traitsFrom: [field.traitCollection, + UITraitCollection(preferredContentSizeCategory: dynamicTypeSize.uiContentSizeCategory)]) + field.font = BrandTypography.uiFont(traits: traits, boldText: legibilityWeight == .bold) field.accessibilityIdentifier = identifier field.accessibilityLabel = label field.textContentType = newPassword ? .newPassword : .password @@ -65,6 +70,26 @@ struct PasswordEntry: UIViewRepresentable { } } +private extension DynamicTypeSize { + var uiContentSizeCategory: UIContentSizeCategory { + switch self { + case .xSmall: return .extraSmall + case .small: return .small + case .medium: return .medium + case .large: return .large + case .xLarge: return .extraLarge + case .xxLarge: return .extraExtraLarge + case .xxxLarge: return .extraExtraExtraLarge + case .accessibility1: return .accessibilityMedium + case .accessibility2: return .accessibilityLarge + case .accessibility3: return .accessibilityExtraLarge + case .accessibility4: return .accessibilityExtraExtraLarge + case .accessibility5: return .accessibilityExtraExtraExtraLarge + @unknown default: return .large + } + } +} + struct PasswordControl: View { let title: String @Binding var text: String @@ -74,7 +99,7 @@ struct PasswordControl: View { @State private var visible = false var body: some View { VStack(alignment: .leading, spacing: 8) { - Text(title).font(.headline) + Text(title).brandFont(.headline) HStack(spacing: 8) { PasswordEntry(text: $text, visible: visible, newPassword: newPassword, identifier: identifier, label: title, focus: focus) .frame(minHeight: 48) diff --git a/SpaceWorks/PaymentsViews.swift b/SpaceWorks/PaymentsViews.swift index f0ade7a..afc6ad1 100644 --- a/SpaceWorks/PaymentsViews.swift +++ b/SpaceWorks/PaymentsViews.swift @@ -12,7 +12,7 @@ struct PaymentsView: View { var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 24) { - Text("Payments").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text("Payments").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) Text("See your charges, payment status, and issued documents.").foregroundStyle(Color.swMuted) Picker("Due date", selection: $dueFilter) { Text("All charges").tag(String?.none) @@ -31,21 +31,21 @@ struct PaymentsView: View { } if state.load == .ready && visibleRecords.isEmpty { SWCard { - Label(dueFilter == nil ? "No charges here" : "No charges match this filter", systemImage: "checkmark.circle").font(.headline) + Label(dueFilter == nil ? "No charges here" : "No charges match this filter", systemImage: "checkmark.circle").brandFont(.headline) Text(dueFilter == nil ? "Your payments will appear here when your makerspace issues a charge." : "Choose another filter to see the other charges.").foregroundStyle(Color.swMuted) } } ForEach(visibleRecords, id: \.id) { row in NavigationLink { PaymentDetailView(state: state, paymentID: row.id) } label: { SWCard { - Text(row.subject_label).font(.headline).foregroundStyle(Color.swInk) + Text(row.subject_label).brandFont(.headline).foregroundStyle(Color.swInk) PaymentMoney(amount: row.amount, currency: row.currency) HStack(alignment: .top) { Text(PaymentCopy.status(row.status?.rawValue)).foregroundStyle(Color.swMuted) Spacer() Image(systemName: "chevron.right").accessibilityHidden(true) } - if let due = row.due_date, !due.isEmpty { Text("Due \(due)").font(.subheadline).foregroundStyle(Color.swMuted) } + if let due = row.due_date, !due.isEmpty { Text("Due \(due)").brandFont(.subheadline).foregroundStyle(Color.swMuted) } } }.buttonStyle(.plain).accessibilityIdentifier("payment-\(row.id)") } @@ -71,17 +71,17 @@ struct PaymentDetailView: View { ScrollView { VStack(alignment: .leading, spacing: 24) { if let row = state.record(paymentID) { - Text("Review payment").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) - Text(row.subject_label).font(.title2.bold()) + Text("Review payment").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) + Text(row.subject_label).brandFont(.title2, weight: .bold) SWCard { - PaymentMoney(amount: row.amount, currency: row.currency).font(.title.bold()) - Text(PaymentCopy.status(row.status?.rawValue)).font(.headline) + PaymentMoney(amount: row.amount, currency: row.currency).brandFont(.title, weight: .bold) + Text(PaymentCopy.status(row.status?.rawValue)).brandFont(.headline) if let due = row.due_date, !due.isEmpty { Text("Due \(due)").foregroundStyle(Color.swMuted) } - Text("Charge #\(row.id)").font(.subheadline).foregroundStyle(Color.swMuted) + Text("Charge #\(row.id)").brandFont(.subheadline).foregroundStyle(Color.swMuted) } if let settlement = row.settlement?.value1 { SWCard { - Text("Received at the desk").font(.headline) + Text("Received at the desk").brandFont(.headline) Text(settlement.received_at, format: .dateTime.day().month().year().hour().minute()).foregroundStyle(Color.swMuted) Text("Method: \(settlement.method)") if !settlement.reference.isEmpty { Text("Reference: \(settlement.reference)").textSelection(.enabled) } @@ -102,11 +102,11 @@ struct PaymentDetailView: View { Button("Check payment status") { Task { await state.checkStatus(paymentID: paymentID) } } .frame(minHeight: 48).disabled(state.busy || !state.canRequest).accessibilityIdentifier("payment-check-status") if !row.refunds.isEmpty { - Text("Refunds").font(.title2.bold()) + Text("Refunds").brandFont(.title2, weight: .bold) ForEach(Array(row.refunds.enumerated()), id: \.offset) { _, refund in SWCard { PaymentMoney(amount: refund.amount, currency: refund.currency) - Text(NativeStatusCopy.label(refund.status)).font(.headline) + Text(NativeStatusCopy.label(refund.status)).brandFont(.headline) if !refund.reason.isEmpty { Text(refund.reason).foregroundStyle(Color.swMuted) } if !refund.reference.isEmpty { Text("Reference: \(refund.reference)").foregroundStyle(Color.swMuted).textSelection(.enabled) } } @@ -115,7 +115,7 @@ struct PaymentDetailView: View { NavigationLink("Receipts and credit notes") { PaymentDocumentsView(state: state, paymentID: paymentID) } .frame(minHeight: 48) } else { - Text("Payment unavailable").font(.title.bold()) + Text("Payment unavailable").brandFont(.title, weight: .bold) Text("Refresh your payments to see the latest information.").foregroundStyle(Color.swMuted) Button("Refresh payments") { Task { await state.refresh() } }.frame(minHeight: 48).disabled(!state.canRequest) } @@ -180,7 +180,7 @@ private struct DemoPaymentOutcomeSheet: View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text("Demo checkout · no money moves").font(.title.bold()).accessibilityAddTraits(.isHeader) + Text("Demo checkout · no money moves").brandFont(.title, weight: .bold).accessibilityAddTraits(.isHeader) Text("These sample results do not open a payment SDK or contact a server.").foregroundStyle(Color.swMuted) choice("Preview confirmed payment", scenario: .paid) choice("Preview waiting for confirmation", scenario: .pending) @@ -198,7 +198,7 @@ private struct DemoPaymentOutcomeSheet: View { dismiss() Task { await state.simulateDemo(paymentID: paymentID, scenario: scenario) } } label: { - Text(title).font(.headline).fixedSize(horizontal: false, vertical: true) + Text(title).brandFont(.headline).fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading).frame(minHeight: 48) }.buttonStyle(SWPrimary()) } @@ -215,7 +215,7 @@ struct PaymentDocumentsView: View { var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 24) { - Text("Receipts and credit notes").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text("Receipts and credit notes").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) Text("Open or share documents issued by your makerspace.").foregroundStyle(Color.swMuted) if state.isDemo { Text("Demo documents · sample data").foregroundStyle(Color.swMuted) } if case .loading = state.documentsLoad { ProgressView("Loading documents…") } @@ -230,7 +230,7 @@ struct PaymentDocumentsView: View { if state.documentBusy { ProgressView("Preparing document…") } ForEach(documents, id: \.id) { document in SWCard { - Text(document.kind.rawValue == "credit_note" ? "Credit note \(document.number)" : "Receipt \(document.number)").font(.headline) + Text(document.kind.rawValue == "credit_note" ? "Credit note \(document.number)" : "Receipt \(document.number)").brandFont(.headline) PaymentMoney(amount: document.amount, currency: document.currency) Text(document.issued_at, format: .dateTime.day().month().year()).foregroundStyle(Color.swMuted) Button("Open PDF") { Task { await state.openDocument(document) } }.frame(minHeight: 48) diff --git a/SpaceWorks/Platform/NativeScanner.swift b/SpaceWorks/Platform/NativeScanner.swift index 79d0819..4d8e512 100644 --- a/SpaceWorks/Platform/NativeScanner.swift +++ b/SpaceWorks/Platform/NativeScanner.swift @@ -35,7 +35,7 @@ struct NativeQRScanner: View { Label(message, systemImage: "camera") .foregroundStyle(Color.swMuted) } - Text("Enter code manually").font(.headline) + Text("Enter code manually").brandFont(.headline) TextField("Code", text: $manual) .textInputAutocapitalization(.never).autocorrectionDisabled() .padding(16).background(Color.swPanel, in: RoundedRectangle(cornerRadius: 10)) diff --git a/SpaceWorks/Resources/Fonts/Gilroy-Bold.ttf b/SpaceWorks/Resources/Fonts/Gilroy-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..55b0f7ad1142995263deceeca2a39c257e264fee GIT binary patch literal 137256 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

Q;3%0?jM%gEDpc(+SW+_#o6YpkfEBbla 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 T2Cu)?Db^7k6zLnD)`a;#Wk*UN literal 0 HcmV?d00001 diff --git a/SpaceWorks/Resources/Fonts/Gilroy-Medium.ttf b/SpaceWorks/Resources/Fonts/Gilroy-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..64e5e535bce1ffe43aca9cf8dbb02ccc79bc2255 GIT binary patch literal 143724 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 B{l+|pw*reVk8>M>jV*s;2L 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 IPg}zLKjtcFMF0Q* literal 0 HcmV?d00001 diff --git a/SpaceWorks/Resources/Fonts/Gilroy-Regular.ttf b/SpaceWorks/Resources/Fonts/Gilroy-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..3ac2ab2f263421512dd59dbb4943b16b0cb52a66 GIT binary patch literal 145148 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

nSVk5KfW_yo*+}IAfvUSj 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 literal 0 HcmV?d00001 diff --git a/SpaceWorks/Resources/Fonts/Gilroy-SemiBold.ttf b/SpaceWorks/Resources/Fonts/Gilroy-SemiBold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..1ebab5344b7a5d893b30a557d37f3f75fb4c03da GIT binary patch literal 139820 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 S{h57^I+8(A-w3QF-2Vk9!Zs}c literal 0 HcmV?d00001 diff --git a/SpaceWorks/Resources/Fonts/Gilroy-provenance.json b/SpaceWorks/Resources/Fonts/Gilroy-provenance.json new file mode 100644 index 0000000..4e0bf8a --- /dev/null +++ b/SpaceWorks/Resources/Fonts/Gilroy-provenance.json @@ -0,0 +1,42 @@ +{ + "conversion": "fontTools4.67.0; WOFFcontainer toSFNT; recalcTimestampfalse; tables/outlines/names retained except requiredheadchecksum adjustment", + "authorization": "Owner supplied Designfontassets; latest fullbrand app request authorizes their use under applicable upstreamlicense. No open-source grant inferred.", + "faces": [ + { + "source": "design/fonts/Gilroy-Regular.woff", + "source_sha256": "3ddd8144d45b5bccb8a54105cac4b5c84512660df31db154583354a86fe3794b", + "native": "Gilroy-Regular.ttf", + "native_sha256": "740a485e236853d80b8a013a6857041f23b65d418ee21ee7088ee113b1a3456f", + "postscript_name": "Gilroy-Regular", + "weight_role": 400, + "glyph_count": 556 + }, + { + "source": "design/fonts/Gilroy-Medium.woff", + "source_sha256": "f7709530ad3c01a0b3746b49c8253a265a03b835b7de6ba4b98dd859cdfb5642", + "native": "Gilroy-Medium.ttf", + "native_sha256": "4e613f3621e2625d8a961543a9abf692d95b94140135cb455fcecaff58cde38e", + "postscript_name": "Gilroy-Medium", + "weight_role": 500, + "glyph_count": 556 + }, + { + "source": "design/fonts/Gilroy-SemiBold.woff", + "source_sha256": "426b718652a4468d7bde05c50601ac0a7f9c2ff11ea3e4abe8902bc2dc182175", + "native": "Gilroy-SemiBold.ttf", + "native_sha256": "33eca191284d4e7e668668d31f5724a1e03758d7c428e2aae2bff12a4a6fa661", + "postscript_name": "Gilroy-SemiBold", + "weight_role": 600, + "glyph_count": 556 + }, + { + "source": "design/fonts/Gilroy-Bold.woff", + "source_sha256": "90dbd59c5b6b9da6c7f3fb89ca16e5d3c41e5e6451e4b51de89bbbd54173d688", + "native": "Gilroy-Bold.ttf", + "native_sha256": "d62f5a713448b45eb6a2f7b7b3777cf88aa0f19f684c19d88c8bc917fd5c34a7", + "postscript_name": "Gilroy-Bold", + "weight_role": 700, + "glyph_count": 556 + } + ] +} diff --git a/SpaceWorks/Resources/Fonts/README.md b/SpaceWorks/Resources/Fonts/README.md index 997192e..0816c85 100644 --- a/SpaceWorks/Resources/Fonts/README.md +++ b/SpaceWorks/Resources/Fonts/README.md @@ -1,6 +1,8 @@ -# Native accent font +# Native Space Works fonts -Unbounded is used for one short branded accent phrase; ordinary interface text uses Apple's native system fonts with Dynamic Type. Gilroy is not embedded because its app license has not been established. +Gilroy Regular, Medium, Semibold and Bold are converted from the owner's supplied Design WOFF assets for app content, with Dynamic Type and explicit heavier faces for Bold Text. The owner requested these assets in the native apps; their inclusion remains subject to the applicable Gilroy license and does not grant an open-source redistribution license. Upstream names/outlines/metrics are preserved; the native container checksum necessarily changes. `Gilroy-provenance.json` records all source/native hashes. Every upstream OS/2 weight field is400, so the app selects the actual supplied PostScript face explicitly rather than relying on synthetic weight lookup. + +Unbounded remains one short accent phrase per layout. Native navigation titles, system activities, security codes and identifiers retain native platform typography where needed. Gilroy is not substituted into the outlined logo masters. `Unbounded-Variable.ttf` is the unmodified variable font outlines converted from the Design submodule's `fonts/Unbounded-Variable.woff2` container using fontTools 4.67.0 and Brotli. Its SIL Open Font License 1.1 is included alongside it; the font's family/PostScript names, glyphs and weight axis (200–900) are preserved. Conversion changes the web container into Apple's supported native TrueType container without renaming the font. diff --git a/SpaceWorks/RootView.swift b/SpaceWorks/RootView.swift index 5a1e3e3..6105e21 100644 --- a/SpaceWorks/RootView.swift +++ b/SpaceWorks/RootView.swift @@ -22,6 +22,7 @@ struct RootView: View { #endif } } + .brandFont(.body) .tint(Color.swAccentInk) .foregroundStyle(Color.swInk) .preferredColorScheme(state.darkMode ? .dark : .light) @@ -97,12 +98,12 @@ private struct ConnectView: View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text(Brand.appName).font(.headline).foregroundStyle(Color.swMuted) + Text(Brand.appName).brandFont(.headline).foregroundStyle(Color.swMuted) BrandWordmark() - Text("Connect to your makerspace").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text("Connect to your makerspace").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) Text("Enter the server address provided by your makerspace.").foregroundStyle(Color.swMuted) VStack(alignment: .leading, spacing: 8) { - Text("Server address").font(.headline) + Text("Server address").brandFont(.headline) TextField("Server address", text: $state.serverInput, prompt: Text(verbatim: "https://spaceworks.example").foregroundColor(Color.swMuted)) .foregroundStyle(Color.swInk) @@ -295,7 +296,7 @@ private struct MemberScreen: View { else { ScrollView { VStack(alignment: .leading, spacing: 24) { SWCard { - Text(auth.displayName).font(.title2.bold()) + Text(auth.displayName).brandFont(.title2, weight: .bold) Text(auth.user?.email ?? "").foregroundStyle(Color.swMuted).textSelection(.enabled) if let error = auth.error { SWError(message: error) } if auth.user?.email_verified == false { diff --git a/SpaceWorks/StaffHandoverView.swift b/SpaceWorks/StaffHandoverView.swift index b6ddb65..1f22348 100644 --- a/SpaceWorks/StaffHandoverView.swift +++ b/SpaceWorks/StaffHandoverView.swift @@ -15,9 +15,9 @@ struct StaffHandoverView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text("Step \(state.step + 1) of \(state.steps.count)").font(.subheadline).foregroundStyle(Color.swMuted) + Text("Step \(state.step + 1) of \(state.steps.count)").brandFont(.subheadline).foregroundStyle(Color.swMuted) ProgressView(value: Double(state.step + 1), total: Double(state.steps.count)).tint(Color.swAccentInk).accessibilityLabel("Handover progress") - Text(currentTitle).font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text(currentTitle).brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) if state.isDemo { Label("Demo handover · no live equipment changes", systemImage: "info.circle").foregroundStyle(Color.swMuted) } if let error = state.error { SWError(message: error).accessibilityIdentifier("staff-handover-error") } if let notice = state.notice { Label(notice, systemImage: "info.circle").foregroundStyle(Color.swMuted) } @@ -27,7 +27,7 @@ struct StaffHandoverView: View { Button("Close") { dismiss() }.buttonStyle(SWPrimary()) } else if let receipt = state.receipt { SWCard { - Label(receipt, systemImage: "checkmark.circle").font(.title2.bold()).accessibilityIdentifier("staff-handover-recorded") + Label(receipt, systemImage: "checkmark.circle").brandFont(.title2, weight: .bold).accessibilityIdentifier("staff-handover-recorded") if let evidence = state.evidenceID { Text("Evidence photo #\(evidence)") } if !state.remark.isEmpty { Text(state.remark) } Text("This summary reflects the recorded handover. Refresh the loan to see its latest status.").foregroundStyle(Color.swMuted) @@ -89,7 +89,7 @@ struct StaffHandoverView: View { } private var container: some View { SWCard { - Text(state.mode.loan == nil ? "Check the container" : "Check the loan code").font(.headline) + Text(state.mode.loan == nil ? "Check the container" : "Check the loan code").brandFont(.headline) Text(state.requiresBox ? "Scan the code attached to this handover. The server will verify it belongs to this makerspace." : "This loan does not require a return scan.").foregroundStyle(Color.swMuted) if state.requiresBox { Button(state.boxCode.isEmpty ? "Scan handover code" : "Scan another code") { scanner = .box }.frame(minHeight: 48) } if !state.boxLabel.isEmpty { Label(state.boxLabel, systemImage: "checkmark.circle") } @@ -98,7 +98,7 @@ struct StaffHandoverView: View { private var borrowerCheck: some View { SWCard { if let row = state.mode.request { - Text(row.requester_display.isEmpty ? row.requester_username : row.requester_display).font(.title2.bold()) + Text(row.requester_display.isEmpty ? row.requester_username : row.requester_display).brandFont(.title2, weight: .bold) Text("Request #\(row.id)").foregroundStyle(Color.swMuted) Text("Check the borrower's membership card or approved identification before handing over the equipment.").foregroundStyle(Color.swMuted) Toggle("I checked the borrower against this request", isOn: $state.borrowerChecked).frame(minHeight: 48).accessibilityIdentifier("staff-borrower-checked") @@ -107,7 +107,7 @@ struct StaffHandoverView: View { } private var borrowers: some View { SWCard { - Text("Choose an eligible borrower").font(.headline) + Text("Choose an eligible borrower").brandFont(.headline) Text("These people are supplied by this makerspace. Selecting a person does not create a sign-in account.").foregroundStyle(Color.swMuted) Picker("Borrower", selection: $state.borrowerID) { Text("Choose a borrower").tag(Optional.none) @@ -120,11 +120,11 @@ struct StaffHandoverView: View { private var equipment: some View { VStack(alignment: .leading, spacing: 24) { if let row = state.mode.request { - ForEach(row.items, id: \.id) { item in SWCard { Text(item.product_name).font(.headline); Text("Accepted: \(item.accepted_quantity)"); if item.requires_asset_qr { Text("Scan each individual asset.").foregroundStyle(Color.swMuted) } } } + ForEach(row.items, id: \.id) { item in SWCard { Text(item.product_name).brandFont(.headline); Text("Accepted: \(item.accepted_quantity)"); if item.requires_asset_qr { Text("Scan each individual asset.").foregroundStyle(Color.swMuted) } } } } else { ForEach(state.products.filter { $0.is_archived != true && ($0.available_quantity ?? 0) > 0 && $0.tracking_mode?.rawValue != "individual" }, id: \.id) { item in SWCard { - Text(item.name).font(.headline) + Text(item.name).brandFont(.headline) Stepper("Quantity: \(state.bulkQuantities[item.id] ?? 0)", value: Binding(get: { state.bulkQuantities[item.id] ?? 0 }, set: { state.bulkQuantities[item.id] = $0 }), in: 0...max(0, item.available_quantity ?? 0)).frame(minHeight: 48).accessibilityLabel("Quantity for \(item.name)").accessibilityValue("\(state.bulkQuantities[item.id] ?? 0)") } } @@ -132,7 +132,7 @@ struct StaffHandoverView: View { if state.products.isEmpty && !state.busy { Button("Reload equipment") { Task { await state.loadProducts() } }.frame(minHeight: 48) } } Button("Scan equipment code") { scanner = .equipment }.frame(minHeight: 48).disabled(state.busy) - Text("\(state.scans.count) equipment codes checked").font(.subheadline).foregroundStyle(Color.swMuted) + Text("\(state.scans.count) equipment codes checked").brandFont(.subheadline).foregroundStyle(Color.swMuted) if !state.duplicateProducts.isEmpty { SWError(message: String(localized: "This equipment is both scanned and selected by quantity. Remove one selection before continuing.")) } ForEach(state.scans.keys.sorted(), id: \.self) { payload in HStack(alignment: .top) { @@ -145,7 +145,7 @@ struct StaffHandoverView: View { private var returnedItems: some View { ForEach(state.returns.indices, id: \.self) { index in SWCard { - Text(state.returns[index].label).font(.headline) + Text(state.returns[index].label).brandFont(.headline) Text("Remaining: \(state.returns[index].remaining)").foregroundStyle(Color.swMuted) if !state.returns[index].metadataValid { SWError(message: String(localized: "Asset details are unavailable. Refresh this loan before recording a return.")) } else if state.returns[index].assets.isEmpty { @@ -169,7 +169,7 @@ struct StaffHandoverView: View { private var review: some View { VStack(alignment: .leading, spacing: 24) { SWCard { - Text("Evidence photo").font(.headline) + Text("Evidence photo").brandFont(.headline) Text("Photograph the equipment and its condition. Avoid faces or unrelated personal information.").foregroundStyle(Color.swMuted) if let photo = state.photo, let image = UIImage(data: photo) { Image(uiImage: image).resizable().scaledToFit().frame(maxHeight: 280).clipShape(RoundedRectangle(cornerRadius: 16)).accessibilityLabel("Evidence photo preview") } Button(state.photo == nil ? "Take evidence photo" : "Retake photo") { Task { await prepareCamera() } }.frame(minHeight: 48).disabled(state.busy) @@ -182,7 +182,7 @@ struct StaffHandoverView: View { } if state.needsKits { SWCard { - Text("Kit checklist").font(.headline) + Text("Kit checklist").brandFont(.headline) Text("Count each component. Enter zero for a missing component.").foregroundStyle(Color.swMuted) if !state.kitsReady { Button("Load kit checklist") { Task { await state.prepareKits() } }.frame(minHeight: 48).disabled(state.busy) } ForEach(state.kits, id: \.key) { item in @@ -194,7 +194,7 @@ struct StaffHandoverView: View { } } } - Text(state.mode.isReturn ? "Return remark (required)" : "Issue remark (optional)").font(.headline) + Text(state.mode.isReturn ? "Return remark (required)" : "Issue remark (optional)").brandFont(.headline) TextField("Describe the equipment condition", text: $state.remark, axis: .vertical).lineLimit(3...8).modifier(StaffInput()).accessibilityIdentifier("staff-handover-remark") if state.mode.isReturn { ForEach(state.returns, id: \.id) { item in Text("\(item.label): \(item.returned) returned, \(item.damaged) damaged, \(item.missing) missing").foregroundStyle(Color.swMuted) } } Text("Confirming records this handover. Review the details before continuing.").foregroundStyle(Color.swMuted) diff --git a/SpaceWorks/StaffViews.swift b/SpaceWorks/StaffViews.swift index bf9fa24..d12510f 100644 --- a/SpaceWorks/StaffViews.swift +++ b/SpaceWorks/StaffViews.swift @@ -6,7 +6,7 @@ struct StaffView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text("Staff tools").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) + Text("Staff tools").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) Text("Review, hand over, and welcome people into your makerspace.").foregroundStyle(Color.swMuted) if state.isDemo { Label("Demo staff tools · sample data", systemImage: "info.circle").foregroundStyle(Color.swMuted) } SWCard { @@ -23,7 +23,7 @@ struct StaffView: View { NavigationLink { StaffEventsView(state: state) } label: { StaffMenuRow(title: Text("Event check-in"), detail: "Scan, review the person, then confirm attendance", symbol: "qrcode.viewfinder") }.accessibilityIdentifier("staff-events") } } - Text("Only the tools allowed by your selected makerspace appear here.").font(.subheadline).foregroundStyle(Color.swMuted) + Text("Only the tools allowed by your selected makerspace appear here.").brandFont(.subheadline).foregroundStyle(Color.swMuted) }.padding(24).frame(maxWidth: 900).frame(maxWidth: .infinity) }.background(Color.swBg).foregroundStyle(Color.swInk).tint(Color.swAccentInk).navigationTitle("Staff tools") } @@ -36,13 +36,13 @@ private struct StaffMenuRow: View { Group { if dynamicTypeSize.isAccessibilitySize { VStack(alignment: .leading, spacing: 8) { - title.font(.headline).fixedSize(horizontal: false, vertical: true) - Text(detail).font(.subheadline).foregroundStyle(Color.swMuted).fixedSize(horizontal: false, vertical: true) + title.brandFont(.headline).fixedSize(horizontal: false, vertical: true) + Text(detail).brandFont(.subheadline).foregroundStyle(Color.swMuted).fixedSize(horizontal: false, vertical: true) }.frame(maxWidth: .infinity, alignment: .leading) } else { HStack(alignment: .top, spacing: 16) { Image(systemName: symbol).font(.title2).accessibilityHidden(true) - VStack(alignment: .leading, spacing: 8) { title.font(.headline); Text(detail).font(.subheadline).foregroundStyle(Color.swMuted) } + VStack(alignment: .leading, spacing: 8) { title.brandFont(.headline); Text(detail).brandFont(.subheadline).foregroundStyle(Color.swMuted) } Spacer(minLength: 0); Image(systemName: "chevron.right").accessibilityHidden(true) } } @@ -65,10 +65,10 @@ struct StaffRequestsView: View { ForEach(state.requests, id: \.id) { row in NavigationLink { StaffRequestDetail(state: state, row: row) } label: { SWCard { - Text(row.requester_display.isEmpty ? row.requester_username : row.requester_display).font(.headline).foregroundStyle(Color.swInk) + Text(row.requester_display.isEmpty ? row.requester_username : row.requester_display).brandFont(.headline).foregroundStyle(Color.swInk) Text("Request #\(row.id) · \(NativeStatusCopy.label(row.status))").foregroundStyle(Color.swMuted) ForEach(row.items, id: \.id) { item in Text("\(item.product_name) × \(state.queue == .pending ? item.requested_quantity : item.accepted_quantity)").foregroundStyle(Color.swInk) } - Label("Review details", systemImage: "chevron.right").font(.subheadline) + Label("Review details", systemImage: "chevron.right").brandFont(.subheadline) } }.buttonStyle(.plain).accessibilityIdentifier("staff-request-\(row.id)") } @@ -89,11 +89,11 @@ private struct StaffRequestDetail: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text(row.requester_display.isEmpty ? row.requester_username : row.requester_display).font(.largeTitle.bold()) + Text(row.requester_display.isEmpty ? row.requester_username : row.requester_display).brandFont(.largeTitle, weight: .bold) Text("Request #\(row.id) · \(NativeStatusCopy.label(row.status))").foregroundStyle(Color.swMuted) ForEach(row.items, id: \.id) { item in SWCard { - Text(item.product_name).font(.headline) + Text(item.product_name).brandFont(.headline) if !item.storage_location.isEmpty { Label(item.storage_location, systemImage: "mappin").foregroundStyle(Color.swMuted) } Text("Requested: \(item.requested_quantity) · Accepted: \(item.accepted_quantity)") if state.queue == .pending && state.can("accept_request") { @@ -135,7 +135,7 @@ private struct StaffDirectLoansView: View { StaffFeedback(load: state.directLoad) { await state.loadDirect() } ForEach(state.directLoans, id: \.id) { row in SWCard { - Text(row.target_label).font(.headline); Text("Loan #\(row.id) · \(NativeStatusCopy.label(row.status))").foregroundStyle(Color.swMuted) + Text(row.target_label).brandFont(.headline); Text("Loan #\(row.id) · \(NativeStatusCopy.label(row.status))").foregroundStyle(Color.swMuted) if let due = row.due_at { Text("Due \(due.formatted(date: .abbreviated, time: .shortened))") } if row.status == "active" && state.canReturn { NavigationLink("Record return") { StaffHandoverView(state: StaffHandoverState(parent: state, mode: .directReturn(row))) }.frame(minHeight: 48) @@ -156,7 +156,7 @@ private struct StaffEventsView: View { StaffFeedback(load: state.eventsLoad) { await state.loadEvents() } ForEach(state.events, id: \.id) { event in NavigationLink { StaffCheckInView(state: StaffCheckInState(parent: state, event: event)) } label: { - SWCard { Text(event.title).font(.headline).foregroundStyle(Color.swInk); Text(event.starts_at, format: .dateTime.day().month().hour().minute()).foregroundStyle(Color.swMuted); Label("Open check-in", systemImage: "qrcode.viewfinder") } + SWCard { Text(event.title).brandFont(.headline).foregroundStyle(Color.swInk); Text(event.starts_at, format: .dateTime.day().month().hour().minute()).foregroundStyle(Color.swMuted); Label("Open check-in", systemImage: "qrcode.viewfinder") } }.buttonStyle(.plain) } if state.eventsLoad == .ready && state.events.isEmpty { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } @@ -175,15 +175,15 @@ private struct StaffCheckInView: View { SWError(message: state.parent.error ?? String(localized: "Your access changed. Return to your makerspace before continuing.")) Button("Close") { dismiss() }.buttonStyle(SWPrimary()) } else { - Text(state.eventTitle).font(.largeTitle.bold()) + Text(state.eventTitle).brandFont(.largeTitle, weight: .bold) Text("Scan a check-in code. Review the person below before recording attendance.").foregroundStyle(Color.swMuted) Button("Scan check-in code") { scanner = true }.buttonStyle(SWPrimary()).disabled(state.busy) if state.busy { ProgressView("Checking registration…") } if let error = state.error { SWError(message: error) } - if let confirmation = state.confirmation { Label(confirmation, systemImage: "checkmark.circle").font(.headline).accessibilityIdentifier("staff-attendance-recorded") } + if let confirmation = state.confirmation { Label(confirmation, systemImage: "checkmark.circle").brandFont(.headline).accessibilityIdentifier("staff-attendance-recorded") } if let person = state.person { SWCard { - Text(person.name).font(.title2.bold()); Text(NativeStatusCopy.label(person.status)) + Text(person.name).brandFont(.title2, weight: .bold); Text(NativeStatusCopy.label(person.status)) Text("Payment: \((person.payment_status ?? String(localized: "Unavailable")).replacingOccurrences(of: "_", with: " "))").foregroundStyle(Color.swMuted) if state.canConfirm { Button("Confirm attendance") { Task { await state.confirm() } }.buttonStyle(SWPrimary()).accessibilityIdentifier("staff-confirm-attendance") } else { Text("This registration cannot be checked in. Scan another code or ask the event manager.").foregroundStyle(Color.swMuted) } @@ -220,7 +220,7 @@ private struct StaffBookingsView: View { if let notice = state.notice { Text(notice).accessibilityIdentifier("staff-booking-notice") } ForEach(state.bookings, id: \.id) { row in SWCard { - Text(row.name).font(.headline); Text(row.starts_at, format: .dateTime.day().month().hour().minute()) + Text(row.name).brandFont(.headline); Text(row.starts_at, format: .dateTime.day().month().hour().minute()) Text(NativeStatusCopy.label(row.status.value1.rawValue)).foregroundStyle(Color.swMuted) if !row.note.isEmpty { Text(row.note) } if row.status.value1.rawValue == "pending" { diff --git a/SpaceWorksTests/BrandTypographyTests.swift b/SpaceWorksTests/BrandTypographyTests.swift new file mode 100644 index 0000000..fec9df9 --- /dev/null +++ b/SpaceWorksTests/BrandTypographyTests.swift @@ -0,0 +1,74 @@ +import CoreText +import SwiftUI +import XCTest +@testable import SpaceWorks + +@MainActor final class BrandTypographyTests: XCTestCase { + func testAllSuppliedFacesLoadWithoutSystemFallbackAndContainInterfaceGlyphs() throws { + for name in ["Gilroy-Regular", "Gilroy-Medium", "Gilroy-SemiBold", "Gilroy-Bold"] { + let font = try XCTUnwrap(UIFont(name: name, size: 17)) + XCTAssertEqual(font.fontName, name) + let characters = Array("Space Works 0123456789".utf16) + var glyphs = Array(repeating: CGGlyph(), count: characters.count) + let coreTextFont = CTFontCreateWithName(font.fontName as CFString, 17, nil) + XCTAssertEqual(CTFontCopyPostScriptName(coreTextFont) as String, name) + XCTAssertTrue(CTFontGetGlyphsForCharacters(coreTextFont, characters, &glyphs, characters.count)) + XCTAssertFalse(glyphs.contains(0)) + } + } + + func testUIKitMetricsScaleActualGilroyFromDefaultToLargestAccessibilityCategory() { + let normal = BrandTypography.uiFont(traits: UITraitCollection(preferredContentSizeCategory: .large), boldText: false) + let enlarged = BrandTypography.uiFont(traits: UITraitCollection(preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge), boldText: false) + XCTAssertEqual(normal.fontName, "Gilroy-Regular") + XCTAssertEqual(enlarged.fontName, "Gilroy-Regular") + XCTAssertGreaterThan(enlarged.pointSize, normal.pointSize * 2) + XCTAssertGreaterThan(enlarged.lineHeight, normal.lineHeight) + } + + func testNativeLegibilityTraitUsesRealHeavierFaceAndKeepsAccessibilityScaling() { + // This is an explicit native trait test, not a claim that the OS Bold Text setting changed. + let traits = UITraitCollection(traitsFrom: [UITraitCollection(legibilityWeight: .bold), + UITraitCollection(preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge)]) + let font = BrandTypography.uiFont(traits: traits, boldText: traits.legibilityWeight == .bold) + XCTAssertEqual(font.fontName, "Gilroy-SemiBold") + XCTAssertGreaterThan(font.pointSize, 34) + let heading = BrandTypography.uiFont(style: .headline, weight: .semibold, traits: traits, boldText: true) + XCTAssertEqual(heading.fontName, "Gilroy-Bold") + } + + func testPasswordRepresentableRefreshesFontWhenSwiftUITypeAndLegibilityChange() async throws { + let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let previous = scene.windows.first(where: \.isKeyWindow) + let window = UIWindow(windowScene: scene) + let password = "Synthetic font test" + let host = UIHostingController(rootView: AnyView(PasswordEntry(text: .constant(password), identifier: "brand-password") + .dynamicTypeSize(.large).environment(\.legibilityWeight, .regular))) + window.rootViewController = host + window.makeKeyAndVisible() + defer { window.isHidden = true; window.rootViewController = nil; previous?.makeKey() } + func field(in view: UIView) -> UITextField? { + if let field = view as? UITextField { return field } + return view.subviews.compactMap { field(in: $0) }.first + } + host.view.layoutIfNeeded() + let input = try XCTUnwrap(field(in: host.view)) + let original = try XCTUnwrap(input.font) + XCTAssertEqual(original.fontName, "Gilroy-Regular") + XCTAssertEqual(input.text, password) + host.rootView = AnyView(PasswordEntry(text: .constant(password), identifier: "brand-password") + .dynamicTypeSize(.accessibility5).environment(\.legibilityWeight, .bold)) + host.view.setNeedsLayout() + host.view.layoutIfNeeded() + let fontUpdated = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "font.fontName == %@", "Gilroy-SemiBold"), object: input) + await fulfillment(of: [fontUpdated], timeout: 2) + host.view.layoutIfNeeded() + let retained = try XCTUnwrap(field(in: host.view)) + XCTAssertTrue(retained === input) + XCTAssertEqual(retained.text, password) + let updated = try XCTUnwrap(retained.font) + XCTAssertEqual(updated.fontName, "Gilroy-SemiBold") + XCTAssertGreaterThan(updated.pointSize, original.pointSize * 2) + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 72853bc..d55ca32 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,13 @@ # Phase notes +## B1 Full brand typography and native iOS navigation — implementation checkpoint (2026-10-18) + +The owner requested full Space Works branding with platform-native iOS navigation. App content now uses the supplied Gilroy Regular/Medium/Semibold/Bold faces, converted from the pinned Design WOFF containers without changing glyph, name or metric tables. Recorded hashes and table comparisons preserve provenance. The upstream weight metadata is 400 for every supplied face, so the shared type system selects each actual PostScript face explicitly. Semantic iOS type styles scale through Dynamic Type; Bold Text chooses a heavier real face. The UIKit password field refreshes with type-size and legibility changes. Monospaced security/reference text, provider identity controls, symbol glyphs, system activities and native navigation geometry retain their platform behavior. Unbounded remains a single short accent phrase. The accessibility Start here heading/action stack avoids the observed narrow Dismiss word break. + +On iOS 26 and newer, native navigation/tab appearances keep the system's Liquid Glass and scroll-edge material, with semantic action tint. The earlier semantic opaque appearance remains for iOS 17–25. This follows Apple's TN3106 and Adopting Liquid Glass guidance; no custom glass cards, navigation replacements, API changes, logo edits or palette changes are introduced. + +Validation at this checkpoint: generation and 42 Python checks pass, including original/native table equality and both configuration font registration; source lint and diff hygiene pass. Four new authored native cases exercise actual font loading/glyphs, UIKit accessibility scaling, native legibility traits, and a hosted password-field font refresh. Intended totals are 298 native/36 UI; Apple compilation, all six runtime profiles, unsigned Release/font bundle audit and actual current-source full-OS review remain required. The legibility test uses explicit native traits and does not claim to change the OS Bold Text setting. Parity: supplied Gilroy weights, primary/secondary roles, semantic brand palette and static masters match Android; navigation remains native to each platform. + ## A8 Usage guide and release handoff — implementation checkpoint (2026-10-17) The exact `5205d18` compact-auth diagnostic passed portrait capture but correctly failed the strict landscape guard after 24 reveal attempts. Actual native event logs and the recording showed a 45.5-point adjustment use the default 500-pixel-per-second drag and release into a much larger scroll, alternating between the header-obscured action and a position beyond the second action. The same small gutter adjustment now uses the existing native slow-drag/0.3-second end-hold pattern already used by shared journey helpers, preventing that default release fling. The visible-region, noninteractive gutter, full-control/readiness requirements, 24-attempt budget and all app source remain unchanged. Corrected exact-source native execution and both actual full-OS captures remain required; this is not an accepted result from the failed diagnostic. diff --git a/docs/WHITE-LABEL.md b/docs/WHITE-LABEL.md index 02e7095..dd42520 100644 --- a/docs/WHITE-LABEL.md +++ b/docs/WHITE-LABEL.md @@ -22,7 +22,7 @@ Optional deployment links and artwork are configured in the same `branding/brand Run the generator tests as well as generation after changing branding. Test a fresh build with your actual name, icon, palette, artwork, support/privacy links and bundle identifier; inspect the installed app and the Release bundle rather than only the JSON configuration. The automated fixture tests prove generator behavior with temporary inputs; they do not validate your deployment pages, signing or store submission. -Ordinary interface text uses Apple's system font with Dynamic Type. The licensed Unbounded native accent font and conversion provenance are in `SpaceWorks/Resources/Fonts/`. Obtain explicit Gilroy app-embedding permission before bundling it. Brand assets retain their upstream licenses; this repo does not grant additional embedding rights. Keep master logos static, undistorted and fully opaque with the Design clear space; motion belongs on sibling content and respects Reduce Motion. +App content uses the owner's supplied Gilroy native faces with Dynamic Type and Bold Text support. Native bars/system activities and monospaced security identifiers retain platform conventions. Gilroy and the Unbounded accent font conversion provenance are in `SpaceWorks/Resources/Fonts/`; upstream licenses still apply and this repository grants no new font rights. Brand assets retain their upstream licenses; this repo does not grant additional embedding rights. Keep master logos static, undistorted and fully opaque with the Design clear space; motion belongs on sibling content and respects Reduce Motion. Register the real Google iOS OAuth audience and optional `google_reversed_client_id` callback scheme. Enable Sign in with Apple for the registered bundle/team. Organization sign-in needs its actual native callback/device-grant contract. Compiled SDK dependencies do not enable a web-only server flow automatically. Never substitute a browser cookie or access-only response for a complete native grant. diff --git a/scripts/audit_bundle.py b/scripts/audit_bundle.py index f8b57bf..819c494 100644 --- a/scripts/audit_bundle.py +++ b/scripts/audit_bundle.py @@ -34,7 +34,7 @@ def plist(path): for key in ['UISupportedInterfaceOrientations', 'UISupportedInterfaceOrientations~ipad']: check(required_orientations <= set(info.get(key, [])), f'Missing supported orientations in {key}') fonts = info.get('UIAppFonts', []) - check('Unbounded-Variable.ttf' in fonts, 'Brand font is not registered') + check({'Gilroy-Regular.ttf', 'Gilroy-Medium.ttf', 'Gilroy-SemiBold.ttf', 'Gilroy-Bold.ttf', 'Unbounded-Variable.ttf'} <= set(fonts), 'Brand fonts are not registered') for font in fonts: check(Path(font).name == font and (app / font).is_file(), f'Registered font is missing from bundle: {font}') check((app / 'Assets.car').is_file(), 'Compiled asset catalog is missing') diff --git a/scripts/generate.py b/scripts/generate.py index 5d058f5..5d5efb2 100644 --- a/scripts/generate.py +++ b/scripts/generate.py @@ -94,7 +94,7 @@ def generate(raster=True): info = {"CFBundleDisplayName": brand["app_name"], "UILaunchScreen": {}, "UIApplicationSceneManifest": {"UIApplicationSupportsMultipleScenes": False}} info["UISupportedInterfaceOrientations"] = ["UIInterfaceOrientationPortrait", "UIInterfaceOrientationLandscapeLeft", "UIInterfaceOrientationLandscapeRight"] info["UISupportedInterfaceOrientations~ipad"] = info["UISupportedInterfaceOrientations"] + ["UIInterfaceOrientationPortraitUpsideDown"] - info["UIAppFonts"] = ["Unbounded-Variable.ttf"] + info["UIAppFonts"] = ["Gilroy-Regular.ttf", "Gilroy-Medium.ttf", "Gilroy-SemiBold.ttf", "Gilroy-Bold.ttf", "Unbounded-Variable.ttf"] info["NSCameraUsageDescription"] = "Scan member and event codes or photograph handover evidence when you choose to." schemes = ["spaceworks"] google_scheme = brand.get("google_reversed_client_id") diff --git a/scripts/tests/test_audit_bundle.py b/scripts/tests/test_audit_bundle.py index 099d953..ed89c59 100644 --- a/scripts/tests/test_audit_bundle.py +++ b/scripts/tests/test_audit_bundle.py @@ -18,11 +18,11 @@ def setUp(self): self.app = Path(self.directory.name) / 'SpaceWorks.app' self.app.mkdir() self.brand = {'ios_bundle_id': 'org.spaceworks.app', 'app_name': 'Space Works'} - self.info = {'CFBundleIdentifier': 'org.spaceworks.app', 'CFBundleDisplayName': 'Space Works', 'UIDeviceFamily': [1, 2], 'CFBundleShortVersionString': '0.1.0', 'CFBundleVersion': '1', 'NSCameraUsageDescription': 'Camera use', 'CFBundleURLTypes': [{'CFBundleURLSchemes': ['spaceworks']}], 'UISupportedInterfaceOrientations': ['UIInterfaceOrientationPortrait', 'UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight'], 'UISupportedInterfaceOrientations~ipad': ['UIInterfaceOrientationPortrait', 'UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight'], 'UIAppFonts': ['Unbounded-Variable.ttf'], 'CFBundleExecutable': 'SpaceWorks', 'CFBundleIcons': {'CFBundlePrimaryIcon': {'CFBundleIconName': 'AppIcon'}}} + self.info = {'CFBundleIdentifier': 'org.spaceworks.app', 'CFBundleDisplayName': 'Space Works', 'UIDeviceFamily': [1, 2], 'CFBundleShortVersionString': '0.1.0', 'CFBundleVersion': '1', 'NSCameraUsageDescription': 'Camera use', 'CFBundleURLTypes': [{'CFBundleURLSchemes': ['spaceworks']}], 'UISupportedInterfaceOrientations': ['UIInterfaceOrientationPortrait', 'UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight'], 'UISupportedInterfaceOrientations~ipad': ['UIInterfaceOrientationPortrait', 'UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight'], 'UIAppFonts': ['Gilroy-Regular.ttf', 'Gilroy-Medium.ttf', 'Gilroy-SemiBold.ttf', 'Gilroy-Bold.ttf', 'Unbounded-Variable.ttf'], 'CFBundleExecutable': 'SpaceWorks', 'CFBundleIcons': {'CFBundlePrimaryIcon': {'CFBundleIconName': 'AppIcon'}}} self.info['UIApplicationSceneManifest'] = {'UIApplicationSupportsMultipleScenes': False} self.write_info() (self.app / 'PrivacyInfo.xcprivacy').write_bytes(plistlib.dumps({'NSPrivacyAccessedAPITypes': [{'NSPrivacyAccessedAPIType': 'NSPrivacyAccessedAPICategoryUserDefaults', 'NSPrivacyAccessedAPITypeReasons': ['CA92.1']}]})) - for name in ['Unbounded-Variable.ttf', 'Assets.car', 'SpaceWorks', 'en.lproj/InfoPlist.strings', 'en.lproj/Localizable.strings', 'SpaceWorksAPI_SpaceWorksAPI.bundle/en.lproj/Localizable.strings']: + for name in ['Gilroy-Regular.ttf', 'Gilroy-Medium.ttf', 'Gilroy-SemiBold.ttf', 'Gilroy-Bold.ttf', 'Unbounded-Variable.ttf', 'Assets.car', 'SpaceWorks', 'en.lproj/InfoPlist.strings', 'en.lproj/Localizable.strings', 'SpaceWorksAPI_SpaceWorksAPI.bundle/en.lproj/Localizable.strings']: path = self.app / name path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(b'fixture') diff --git a/scripts/tests/test_brand_fonts.py b/scripts/tests/test_brand_fonts.py new file mode 100644 index 0000000..cdf7566 --- /dev/null +++ b/scripts/tests/test_brand_fonts.py @@ -0,0 +1,49 @@ +import hashlib +import json +from pathlib import Path +import plistlib +import struct +import unittest +import zlib + +ROOT = Path(__file__).resolve().parents[2] + + +def tables(data): + result = {} + if data[:4] == b'wOFF': + count = struct.unpack_from('>H', data, 12)[0] + for index in range(count): + tag, offset, compressed, original, _ = struct.unpack_from('>4sIIII', data, 44 + index * 20) + value = data[offset:offset + compressed] + result[tag] = zlib.decompress(value) if compressed < original else value + else: + count = struct.unpack_from('>H', data, 4)[0] + for index in range(count): + tag, _, offset, length = struct.unpack_from('>4sIII', data, 12 + index * 16) + result[tag] = data[offset:offset + length] + # The SFNT checksum adjustment necessarily changes with the native container. + result[b'head'] = result[b'head'][:8] + bytes(4) + result[b'head'][12:] + return result + + +class BrandFontTests(unittest.TestCase): + def test_native_containers_preserve_every_original_font_table_and_provenance(self): + root = ROOT / 'SpaceWorks/Resources/Fonts' + provenance = json.loads((root / 'Gilroy-provenance.json').read_text()) + self.assertEqual(len(provenance['faces']), 4) + for face in provenance['faces']: + source = (ROOT / face['source']).read_bytes() + native = (root / face['native']).read_bytes() + self.assertEqual(hashlib.sha256(source).hexdigest(), face['source_sha256']) + self.assertEqual(hashlib.sha256(native).hexdigest(), face['native_sha256']) + self.assertEqual(native[:4], b'\x00\x01\x00\x00') + self.assertEqual(tables(source), tables(native), 'No glyph, name or metric table may change during conversion') + + def test_both_generated_app_configurations_register_every_bundled_brand_face(self): + expected = {'Gilroy-Regular.ttf', 'Gilroy-Medium.ttf', 'Gilroy-SemiBold.ttf', 'Gilroy-Bold.ttf', 'Unbounded-Variable.ttf'} + for configuration in ['Debug', 'Release']: + info = plistlib.loads((ROOT / f'SpaceWorks/Generated/Info-{configuration}.plist').read_bytes()) + self.assertEqual(set(info['UIAppFonts']), expected) + for name in expected: + self.assertTrue((ROOT / 'SpaceWorks/Resources/Fonts' / name).is_file()) From d93aa950c149625ba6cbfb0b4cf403c7a28a7c02 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:26:45 +0530 Subject: [PATCH 63/98] test(b2): center auth actions and record native rotation policy Co-Authored-By: Codex --- .github/workflows/ios.yml | 32 ++++ .../NativeOrientationDiagnostics.swift | 156 ++++++++++++++++++ SpaceWorks/RootView.swift | 1 + SpaceWorksUITests/MainFlowTests.swift | 4 +- SpaceWorksUITests/NativeTestLaunch.swift | 17 +- docs/CHANGELOG.md | 6 + 6 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 SpaceWorks/Platform/NativeOrientationDiagnostics.swift diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 1c43eb1..41f0a87 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -73,6 +73,30 @@ jobs: run: | set -o pipefail xcodebuild build-for-testing -project SpaceWorks.xcodeproj -scheme SpaceWorks -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -skipPackagePluginValidation "SPACEWORKS_TEST_PROFILE=$DEVICE_PROFILE" ONLY_ACTIVE_ARCH=YES CODE_SIGN_STYLE=Manual CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= | tee build.log + - name: Inspect actual built Debug bundle metadata + run: | + python3 - <<'PY' + import json, os, pathlib, plistlib, subprocess + app = pathlib.Path('DerivedData/Build/Products/Debug-iphonesimulator/SpaceWorks.app') + info = plistlib.loads((app / 'Info.plist').read_bytes()) + source = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip() + required = {'UIInterfaceOrientationPortrait', 'UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight'} + orientations = {key: value for key, value in info.items() if key.startswith('UISupportedInterfaceOrientations')} + scene = info.get('UIApplicationSceneManifest', {}) + validations = { + 'source_matches_checkout': source == os.environ['GITHUB_SHA'], + 'single_scene_explicit': scene.get('UIApplicationSupportsMultipleScenes') is False, + 'phone_and_tablet_families': set(info.get('UIDeviceFamily', [])) == {1, 2}, + 'phone_orientations': required <= set(info.get('UISupportedInterfaceOrientations~iphone', info.get('UISupportedInterfaceOrientations', []))), + 'tablet_orientations': required <= set(info.get('UISupportedInterfaceOrientations~ipad', []))} + report = {'kind': 'canonical-built-debug-metadata', 'source_sha': source, + 'profile': os.environ['DEVICE_PROFILE'], 'bundle_id': info.get('CFBundleIdentifier'), + 'sdk': info.get('DTSDKName'), 'device_families': info.get('UIDeviceFamily'), + 'fonts': info.get('UIAppFonts', []), 'scene_manifest': scene, + 'orientations': orientations, 'validations': validations} + pathlib.Path('validation-debug-bundle.json').write_text(json.dumps(report, indent=2) + '\n') + assert all(validations.values()), validations + PY - name: Run native state and API tests timeout-minutes: 10 run: | @@ -96,6 +120,12 @@ jobs: - name: Audit unsigned Release bundle resources if: success() && matrix.device == 'phone' run: python3 scripts/audit_bundle.py DerivedData/Build/Products/Release-iphoneos/SpaceWorks.app + - name: Collect read-only native orientation diagnostics + if: always() + run: | + if [ -n "$SIMULATOR_ID" ]; then + xcrun simctl spawn "$SIMULATOR_ID" log show --last 120m --style compact --predicate 'subsystem == "org.spaceworks.orientation-diagnostics" AND category == "orientation"' > orientation-system.log 2>&1 || true + fi - name: Archive validation evidence if: always() uses: actions/upload-artifact@v4 @@ -109,6 +139,8 @@ jobs: release-build.log validation-device.json validation-bundle.json + validation-debug-bundle.json + orientation-system.log DerivedData/Build/Intermediates.noindex/BuildToolPluginIntermediates/**/GeneratedSources/*.swift SpaceWorks.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved retention-days: 7 diff --git a/SpaceWorks/Platform/NativeOrientationDiagnostics.swift b/SpaceWorks/Platform/NativeOrientationDiagnostics.swift new file mode 100644 index 0000000..6adcbbe --- /dev/null +++ b/SpaceWorks/Platform/NativeOrientationDiagnostics.swift @@ -0,0 +1,156 @@ +import SwiftUI + +#if DEBUG +import UIKit +import OSLog + +/// Read-only UI-test diagnostics. Never creates a controller/window or requests orientation. +private struct NativeOrientationDiagnostics: UIViewRepresentable { + private var enabled: Bool { + let arguments = ProcessInfo.processInfo.arguments + return arguments.contains("--ui-testing") && arguments.contains("--ui-orientation-diagnostics") + } + func makeUIView(context: Context) -> OrientationDiagnosticsView { + let view = OrientationDiagnosticsView(frame: .zero) + view.isUserInteractionEnabled = false + view.isAccessibilityElement = false + view.enabled = enabled + return view + } + func updateUIView(_ uiView: OrientationDiagnosticsView, context: Context) { + uiView.enabled = enabled + uiView.scheduleRead(reason: "view-update") + } + static func dismantleUIView(_ uiView: OrientationDiagnosticsView, coordinator: ()) { + uiView.stop() + } +} + +/// Token cleanup is thread-neutral even if UIKit releases the view outside its usual lifecycle. +private final class OrientationObserverBag { + var tokens: [NSObjectProtocol] = [] + func cancel() { + for token in tokens { NotificationCenter.default.removeObserver(token) } + tokens.removeAll() + } + deinit { cancel() } +} + +@MainActor private final class OrientationDiagnosticsView: UIView { + var enabled = false + private let observers = OrientationObserverBag() + private var readScheduled = false + private var readRevision = 0 + private var lastState: Data? + private static let logger = Logger(subsystem: "org.spaceworks.orientation-diagnostics", category: "orientation") + + override func didMoveToWindow() { + super.didMoveToWindow() + stop() + guard enabled, window != nil else { return } + for name in [UIDevice.orientationDidChangeNotification, UIScene.didActivateNotification, + UIScene.willDeactivateNotification] { + let token = NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] notification in + MainActor.assumeIsolated { + self?.read(reason: "notification:" + notification.name.rawValue) + } + } + observers.tokens.append(token) + } + scheduleRead(reason: "attached") + } + override func layoutSubviews() { + super.layoutSubviews() + scheduleRead(reason: "layout") + } + func stop() { + readRevision += 1 + readScheduled = false + observers.cancel() + lastState = nil + } + func scheduleRead(reason: String) { + guard enabled, let window, !readScheduled else { return } + let revision = readRevision + readScheduled = true + DispatchQueue.main.async { [weak self, weak window] in + guard let self, self.readRevision == revision else { return } + self.readScheduled = false + guard let window, self.window === window else { return } + self.read(reason: reason) + } + } + + private func read(reason: String) { + guard enabled, let window, let scene = window.windowScene else { return } + // No view loading: .view is read only when UIKit says it is already loaded. + var visited = Set() + var remaining = 128 + func controller(_ value: UIViewController, path: String, depth: Int) -> [[String: Any]] { + guard depth < 12, remaining > 0, visited.insert(ObjectIdentifier(value)).inserted else { return [] } + remaining -= 1 + var node: [String: Any] = ["path": path, "class": String(describing: type(of: value)), + "supported_mask": value.supportedInterfaceOrientations.rawValue, + "should_autorotate": value.shouldAutorotate, + "view_loaded": value.isViewLoaded] + if value.isViewLoaded { node["view_frame"] = geometry(value.view.frame) } + if let tabs = value as? UITabBarController { node["selected_tab_index"] = tabs.selectedIndex } + if let navigation = value as? UINavigationController { + node["navigation_depth"] = navigation.viewControllers.count + node["visible_class"] = navigation.visibleViewController.map { String(describing: type(of: $0)) } ?? "none" + } + var rows = [node] + if let presented = value.presentedViewController { + rows += controller(presented, path: path + ".presented", depth: depth + 1) + } + // Include unselected tabs: container orientation policy may consult their masks. + for (index, child) in value.children.enumerated() { + rows += controller(child, path: path + ".child[\(index)]", depth: depth + 1) + } + return rows + } + let windows: [[String: Any]] = scene.windows.prefix(16).enumerated().map { index, item in + var row: [String: Any] = ["index": index, "class": String(describing: type(of: item)), + "key": item.isKeyWindow, "hidden": item.isHidden, + "level": item.windowLevel.rawValue, "frame": geometry(item.frame), + "bounds": geometry(item.bounds)] + row["controllers"] = item.rootViewController.map { controller($0, path: "window[\(index)].root", depth: 0) } ?? [] + return row + } + var record: [String: Any] = ["kind": "native-orientation-read-only", + "device_orientation": UIDevice.current.orientation.rawValue, + "device_idiom": UIDevice.current.userInterfaceIdiom.rawValue, + "scene_interface_orientation": scene.interfaceOrientation.rawValue, + "scene_activation": scene.activationState.rawValue, + "host_window_frame": geometry(window.frame), + "scene_bounds": geometry(scene.coordinateSpace.bounds), + "screen_bounds": geometry(window.screen.bounds), "windows": windows, + "controller_limit_reached": remaining == 0] + guard let fingerprint = try? JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) else { return } + // Keep actual notifications even if policy is unchanged; suppress layout-only duplicates. + if !reason.hasPrefix("notification:"), fingerprint == lastState { return } + lastState = fingerprint + record["event"] = reason + record["uptime_seconds"] = ProcessInfo.processInfo.systemUptime + guard let bytes = try? JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]), + let text = String(data: bytes, encoding: .utf8) else { return } + print("SpaceWorksOrientation " + text) + Self.logger.info("SpaceWorksOrientation \(text, privacy: .public)") + } + private func geometry(_ frame: CGRect) -> [Any] { + [frame.minX, frame.minY, frame.width, frame.height].map { value in + value.isFinite ? Double(value) as Any : "nonfinite" as Any + } + } +} +#endif + +extension View { + @ViewBuilder func nativeOrientationDiagnostics() -> some View { + #if DEBUG + background(NativeOrientationDiagnostics().frame(width: 0, height: 0).accessibilityHidden(true)) + #else + self + #endif + } +} diff --git a/SpaceWorks/RootView.swift b/SpaceWorks/RootView.swift index 6105e21..c66ca87 100644 --- a/SpaceWorks/RootView.swift +++ b/SpaceWorks/RootView.swift @@ -42,6 +42,7 @@ struct RootView: View { }.background(Color.swSurface) } .nativePrivacyShield() + .nativeOrientationDiagnostics() .safeAreaInset(edge: .bottom) { MobileLinkNotice(router: linkRouter) } .task { installTapHandler() diff --git a/SpaceWorksUITests/MainFlowTests.swift b/SpaceWorksUITests/MainFlowTests.swift index 6943eca..c858a80 100644 --- a/SpaceWorksUITests/MainFlowTests.swift +++ b/SpaceWorksUITests/MainFlowTests.swift @@ -108,8 +108,8 @@ final class MainFlowTests: XCTestCase { } XCTAssertTrue(scroll.isHittable) let actionsFrame = signInFrame.union(createFrame) - let translation = actionsFrame.minY < viewport.minY - ? viewport.minY - actionsFrame.minY + 2 : viewport.maxY - actionsFrame.maxY - 2 + // Center the two controls within real slack instead of repeatedly aligning a clipped edge. + let translation = viewport.midY - actionsFrame.midY let adjustmentLimit = min(120, viewport.height * 0.4) if abs(translation) <= adjustmentLimit { let movement = (translation < 0 ? -1.0 : 1.0) * min(max(abs(translation), 12), adjustmentLimit) diff --git a/SpaceWorksUITests/NativeTestLaunch.swift b/SpaceWorksUITests/NativeTestLaunch.swift index 4a5988b..86fe1f2 100644 --- a/SpaceWorksUITests/NativeTestLaunch.swift +++ b/SpaceWorksUITests/NativeTestLaunch.swift @@ -4,11 +4,21 @@ import UIKit /// UI-test runner policy. It never enters the application or its Release target. extension XCUIApplication { func rotateToLandscapeAndWait(timeout: TimeInterval = 10) { + let diagnostic = launchArguments.contains("--ui-orientation-diagnostics") + var lastFrame = frame + if diagnostic { + print("SpaceWorksOrientationTest before device=\(XCUIDevice.shared.orientation.rawValue) frame=\(lastFrame)") + } XCUIDevice.shared.orientation = .landscapeLeft let orientation = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in - self.frame.width > self.frame.height + lastFrame = self.frame + return lastFrame.width > lastFrame.height }, object: nil) - XCTAssertEqual(XCTWaiter.wait(for: [orientation], timeout: timeout), .completed, + let result = XCTWaiter.wait(for: [orientation], timeout: timeout) + if diagnostic { + print("SpaceWorksOrientationTest after device=\(XCUIDevice.shared.orientation.rawValue) lastPredicateFrame=\(lastFrame) waitResult=\(result.rawValue)") + } + XCTAssertEqual(result, .completed, "Requested landscape orientation must actually be visible.") } @@ -17,6 +27,9 @@ extension XCUIApplication { let profile = ProcessInfo.processInfo.environment["SPACEWORKS_TEST_PROFILE"] ?? "phone" XCTAssertTrue(profiles.contains(profile), "The test scheme must receive a supported matrix profile.") XCTAssertTrue(launchArguments.contains("--ui-testing"), "Profile launches require isolated Debug fixtures.") + if !launchArguments.contains("--ui-orientation-diagnostics") { + launchArguments.append("--ui-orientation-diagnostics") + } let dark = !launchArguments.contains("--ui-start-light") && ["large", "tablet-landscape"].contains(profile) let landscape = !launchArguments.contains("--ui-start-portrait") && profile == "tablet-landscape" let reduceMotion = profile == "large-tablet" diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d55ca32..4f26cc6 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,11 @@ # Phase notes +## B2 Native orientation evidence and compact action framing — diagnostic checkpoint (2026-10-18) + +The exact 3d3 compact recording showed slow short reveal gestures leave the two actions at alternating positions or with the primary control's upper edge behind the fixed header. The test now targets the center of their combined frame within the actual visible viewport, using its available interior slack. The existing 24-attempt limit, full-control bounds, enabled/hittable foreground checks, clear gutter, capped slow gesture/end hold, far native swipes and all outcomes remain unchanged. No app reachability or UIKit threshold cause is claimed. + +A DEBUG-only, explicitly UI-test-enabled zero-size background view records bounded existing controller masks, device/scene orientation and window geometry. It never creates a controller/window, changes policy, forces geometry, generates device notifications or schedules timers. Weak mount guards remove observers on teardown. The test retains its original 10-second actual geometry assertion and logs pre-request/cached post-predicate frames. The canonical workflow reads the actual built Debug Info.plist, records exact checked-out source/profile/fonts/scene/orientations, and refuses invalid family/orientation/single-scene metadata. It archives only the diagnostic logger's exact subsystem/category over the bounded job window; no broad app content log is exported. This observes the still-unexplained rotation failures rather than treating a passing repeat as a causal fix. No test case or timeout is removed or enlarged. Native compilation and exact current-source execution remain required. + ## B1 Full brand typography and native iOS navigation — implementation checkpoint (2026-10-18) The owner requested full Space Works branding with platform-native iOS navigation. App content now uses the supplied Gilroy Regular/Medium/Semibold/Bold faces, converted from the pinned Design WOFF containers without changing glyph, name or metric tables. Recorded hashes and table comparisons preserve provenance. The upstream weight metadata is 400 for every supplied face, so the shared type system selects each actual PostScript face explicitly. Semantic iOS type styles scale through Dynamic Type; Bold Text chooses a heavier real face. The UIKit password field refreshes with type-size and legibility changes. Monospaced security/reference text, provider identity controls, symbol glyphs, system activities and native navigation geometry retain their platform behavior. Unbounded remains a single short accent phrase. The accessibility Start here heading/action stack avoids the observed narrow Dismiss word break. From 1731d12a655f8b6a00a38d70cf45359e556dce34 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:36:07 +0530 Subject: [PATCH 64/98] feat(b3): complete brand conformance in codes and checkout Co-Authored-By: Codex --- .github/workflows/ios.yml | 2 +- SpaceWorks/AccountViews.swift | 2 +- SpaceWorks/AuthView.swift | 2 +- SpaceWorks/BookingViews.swift | 2 +- SpaceWorks/BrandTypography.swift | 10 ++-- SpaceWorks/DemoMode.swift | 4 +- SpaceWorks/MemberActionViews.swift | 2 +- SpaceWorks/Platform/NativePayments.swift | 48 +++++++++++-------- .../Resources/Fonts/Gilroy-provenance.json | 4 +- SpaceWorks/Resources/Fonts/README.md | 2 +- SpaceWorksTests/NativePaymentsTests.swift | 12 +++++ docs/CHANGELOG.md | 6 +++ docs/WHITE-LABEL.md | 2 +- 13 files changed, 65 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 41f0a87..9be5722 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -94,7 +94,7 @@ jobs: 'sdk': info.get('DTSDKName'), 'device_families': info.get('UIDeviceFamily'), 'fonts': info.get('UIAppFonts', []), 'scene_manifest': scene, 'orientations': orientations, 'validations': validations} - pathlib.Path('validation-debug-bundle.json').write_text(json.dumps(report, indent=2) + '\n') + pathlib.Path('validation-debug-bundle.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') assert all(validations.values()), validations PY - name: Run native state and API tests diff --git a/SpaceWorks/AccountViews.swift b/SpaceWorks/AccountViews.swift index 655e261..10aae87 100644 --- a/SpaceWorks/AccountViews.swift +++ b/SpaceWorks/AccountViews.swift @@ -151,7 +151,7 @@ struct AccountSecurityCompletionView: View { Text(codes.isEmpty ? "Sign in again" : "Save your recovery codes").brandFont(.largeTitle, weight: .bold) Text(message).foregroundStyle(Color.swMuted) if !codes.isEmpty { - SWCard { Text(codes.joined(separator: "\n")).font(.body.monospaced()).textSelection(.enabled).privacySensitive() } + SWCard { Text(codes.joined(separator: "\n")).brandFont(.body, weight: .medium).monospacedDigit().textSelection(.enabled).privacySensitive() } Text("Keep these codes somewhere safe. Each code can be used once.").foregroundStyle(Color.swMuted) Toggle("I saved my recovery codes", isOn: $saved).frame(minHeight: 48).accessibilityIdentifier("account-recovery-saved") } diff --git a/SpaceWorks/AuthView.swift b/SpaceWorks/AuthView.swift index 7dd5561..90c191d 100644 --- a/SpaceWorks/AuthView.swift +++ b/SpaceWorks/AuthView.swift @@ -171,7 +171,7 @@ struct SignInView: View { Text("Less searching.\nMore making.").font(.custom("Unbounded-Regular", size: 24, relativeTo: .title2).weight(.medium)) Text("Borrow hardware. Make room for your next idea.").brandFont(.title3) }.padding(40).frame(maxWidth: 460, minHeight: 460, alignment: .leading) - .background(Color.swSuccess, in: RoundedRectangle(cornerRadius: 28)).foregroundStyle(Color.swOnSuccess) + .background(Color.swSurface, in: RoundedRectangle(cornerRadius: 28)).foregroundStyle(Color.swInk) .accessibilityHidden(true) } }.padding(24).frame(maxWidth: .infinity, minHeight: geometry.size.height) diff --git a/SpaceWorks/BookingViews.swift b/SpaceWorks/BookingViews.swift index 5f75ff7..ab02df7 100644 --- a/SpaceWorks/BookingViews.swift +++ b/SpaceWorks/BookingViews.swift @@ -132,7 +132,7 @@ struct MemberBookingDetailView: View { Text(intervalLabel(current.starts_at, current.ends_at, timezone: current.timezone)) Text(current.timezone).brandFont(.caption).foregroundStyle(Color.swMuted) Text(NativeStatusCopy.label(current.status.value1.rawValue)).brandFont(.headline) - Text("Booking reference").brandFont(.headline); Text(current.public_token).font(.body.monospaced()).textSelection(.enabled) + Text("Booking reference").brandFont(.headline); Text(current.public_token).brandFont(.body, weight: .medium).monospacedDigit().textSelection(.enabled) if let error { SWError(message: error) } if current.can_cancel { Text("Cancellation cutoff: \(current.cancellation_cutoff_at.formatted(date: .abbreviated, time: .shortened))").foregroundStyle(Color.swMuted) diff --git a/SpaceWorks/BrandTypography.swift b/SpaceWorks/BrandTypography.swift index c4b9f41..7ace982 100644 --- a/SpaceWorks/BrandTypography.swift +++ b/SpaceWorks/BrandTypography.swift @@ -28,13 +28,17 @@ enum BrandTypography { static func font(_ style: Font.TextStyle, weight: Weight, boldText: Bool) -> Font { .custom(weight.face(boldText: boldText), size: size(style), relativeTo: style) } + static func unscaledFont(size: CGFloat, weight: Weight = .regular, boldText: Bool = false) -> UIFont { + guard let font = UIFont(name: weight.face(boldText: boldText), size: size) else { + preconditionFailure("Missing registered Space Works font") + } + return font + } static func uiFont(style: UIFont.TextStyle = .body, weight: Weight = .regular, traits: UITraitCollection, boldText: Bool) -> UIFont { let base = UIFontDescriptor.preferredFontDescriptor(withTextStyle: style, compatibleWith: UITraitCollection(preferredContentSizeCategory: .large)).pointSize - guard let font = UIFont(name: weight.face(boldText: boldText), size: base) else { - preconditionFailure("Missing registered Space Works font") - } + let font = unscaledFont(size: base, weight: weight, boldText: boldText) return UIFontMetrics(forTextStyle: style).scaledFont(for: font, compatibleWith: traits) } } diff --git a/SpaceWorks/DemoMode.swift b/SpaceWorks/DemoMode.swift index 526dd28..1c5458c 100644 --- a/SpaceWorks/DemoMode.swift +++ b/SpaceWorks/DemoMode.swift @@ -194,7 +194,7 @@ struct DemoAuthView: View { if session.scenario == .enrollment { QRCodeView(value: "otpauth://totp/SpaceWorks:demo?secret=JBSWY3DPEHPK3PXP&issuer=SpaceWorks") Text("Can't scan? Enter this key").brandFont(.headline) - Text(verbatim: "JBSWY3DPEHPK3PXP").font(.body.monospaced()).textSelection(.enabled) + Text(verbatim: "JBSWY3DPEHPK3PXP").brandFont(.body, weight: .medium).monospacedDigit().textSelection(.enabled) } Text("Authenticator or recovery code").brandFont(.headline) TextField("Authenticator or recovery code", text: $session.code).demoField() @@ -206,7 +206,7 @@ struct DemoAuthView: View { case .recovery: Text("Save your recovery codes").brandFont(.largeTitle, weight: .bold) Text("Sample codes for this demo. Real codes appear once after enrollment.").foregroundStyle(Color.swMuted) - Text(verbatim: "sample-recovery\nsample-backup").font(.body.monospaced()).textSelection(.enabled) + Text(verbatim: "sample-recovery\nsample-backup").brandFont(.body, weight: .medium).monospacedDigit().textSelection(.enabled) Button("I saved my codes. Continue") { if session.securityEnrollment { session.securityEnded(codes: [], message: "Two-step sign-in was set up. Sign in again to continue.") } else { session.stage = session.membershipStage } }.buttonStyle(SWPrimary()) case .makerspace: Text("Choose a makerspace").brandFont(.largeTitle, weight: .bold) diff --git a/SpaceWorks/MemberActionViews.swift b/SpaceWorks/MemberActionViews.swift index 241babd..b9ecefe 100644 --- a/SpaceWorks/MemberActionViews.swift +++ b/SpaceWorks/MemberActionViews.swift @@ -17,7 +17,7 @@ struct RequestCartView: View { Text(NativeStatusCopy.label(receipt.status)).brandFont(.title2) Text("Your makerspace will review your request and confirm collection and due dates.").foregroundStyle(Color.swMuted) Text("Request reference").brandFont(.headline) - Text(receipt.public_token).font(.body.monospaced()).textSelection(.enabled) + Text(receipt.public_token).brandFont(.body, weight: .medium).monospacedDigit().textSelection(.enabled) Button("Done") { dismiss() }.buttonStyle(SWPrimary()) } } else { diff --git a/SpaceWorks/Platform/NativePayments.swift b/SpaceWorks/Platform/NativePayments.swift index b9ebd03..2aaac22 100644 --- a/SpaceWorks/Platform/NativePayments.swift +++ b/SpaceWorks/Platform/NativePayments.swift @@ -46,6 +46,34 @@ enum NativePaymentInput { #endif var hasInFlightPayment: Bool { callback.isActive } + #if canImport(StripePaymentSheet) + /// Stripe owns sizing, weight resolution and Dynamic Type; pass a fresh unscaled base. + static func stripeAppearance() -> PaymentSheet.Appearance { + var appearance = PaymentSheet.Appearance() + appearance.font.base = BrandTypography.unscaledFont(size: UIFont.systemFontSize) + appearance.colors.primary = UIColor(Color.swAccentInk) + appearance.colors.background = UIColor(Color.swPanel) + appearance.colors.componentBackground = UIColor(Color.swSurface) + appearance.colors.componentBorder = UIColor(Color.swOutline) + appearance.colors.componentDivider = UIColor(Color.swLine) + appearance.colors.text = UIColor(Color.swInk) + appearance.colors.textSecondary = UIColor(Color.swMuted) + appearance.colors.componentText = UIColor(Color.swInk) + appearance.colors.componentPlaceholderText = UIColor(Color.swMuted) + appearance.colors.icon = UIColor(Color.swMuted) + appearance.colors.danger = UIColor(Color.swDanger) + appearance.cornerRadius = 10 + appearance.borderWidth = 1 + appearance.shadow = .disabled + appearance.primaryButton.backgroundColor = UIColor(Color.swAccent) + appearance.primaryButton.textColor = UIColor(Color.swOnAccent) + appearance.primaryButton.height = 48 + appearance.primaryButton.successBackgroundColor = UIColor(Color.swSuccessSoft) + appearance.primaryButton.successTextColor = UIColor(Color.swSuccessInk) + return appearance + } + #endif + func presentStripe(clientSecret: String, publishableKey: String, customerID: String? = nil, ephemeralKey: String? = nil, returnURL: URL? = nil, isDemo: Bool, from controller: UIViewController, completion: @escaping (NativePaymentOutcome) -> Void) { @@ -62,25 +90,7 @@ enum NativePaymentInput { if let customerID, let ephemeralKey { configuration.customer = .init(id: customerID, ephemeralKeySecret: ephemeralKey) } - configuration.appearance.colors.primary = UIColor(Color.swAccentInk) - configuration.appearance.colors.background = UIColor(Color.swPanel) - configuration.appearance.colors.componentBackground = UIColor(Color.swSurface) - configuration.appearance.colors.componentBorder = UIColor(Color.swOutline) - configuration.appearance.colors.componentDivider = UIColor(Color.swLine) - configuration.appearance.colors.text = UIColor(Color.swInk) - configuration.appearance.colors.textSecondary = UIColor(Color.swMuted) - configuration.appearance.colors.componentText = UIColor(Color.swInk) - configuration.appearance.colors.componentPlaceholderText = UIColor(Color.swMuted) - configuration.appearance.colors.icon = UIColor(Color.swMuted) - configuration.appearance.colors.danger = UIColor(Color.swDanger) - configuration.appearance.cornerRadius = 10 - configuration.appearance.borderWidth = 1 - configuration.appearance.shadow = .disabled - configuration.appearance.primaryButton.backgroundColor = UIColor(Color.swAccent) - configuration.appearance.primaryButton.textColor = UIColor(Color.swOnAccent) - configuration.appearance.primaryButton.height = 48 - configuration.appearance.primaryButton.successBackgroundColor = UIColor(Color.swSuccessSoft) - configuration.appearance.primaryButton.successTextColor = UIColor(Color.swSuccessInk) + configuration.appearance = Self.stripeAppearance() let sheet = PaymentSheet(paymentIntentClientSecret: clientSecret, configuration: configuration) stripe = sheet sheet.present(from: controller) { [weak self] result in diff --git a/SpaceWorks/Resources/Fonts/Gilroy-provenance.json b/SpaceWorks/Resources/Fonts/Gilroy-provenance.json index 4e0bf8a..efade2d 100644 --- a/SpaceWorks/Resources/Fonts/Gilroy-provenance.json +++ b/SpaceWorks/Resources/Fonts/Gilroy-provenance.json @@ -1,6 +1,6 @@ { - "conversion": "fontTools4.67.0; WOFFcontainer toSFNT; recalcTimestampfalse; tables/outlines/names retained except requiredheadchecksum adjustment", - "authorization": "Owner supplied Designfontassets; latest fullbrand app request authorizes their use under applicable upstreamlicense. No open-source grant inferred.", + "conversion": "fontTools 4.67.0; WOFF container to native SFNT; recalcTimestamp false; original tables, outlines and names retained except the required head checksum adjustment", + "authorization": "Owner-supplied Design font assets; the full-brand native-app request authorizes their use under applicable upstream licensing. No open-source grant is inferred.", "faces": [ { "source": "design/fonts/Gilroy-Regular.woff", diff --git a/SpaceWorks/Resources/Fonts/README.md b/SpaceWorks/Resources/Fonts/README.md index 0816c85..e084ebd 100644 --- a/SpaceWorks/Resources/Fonts/README.md +++ b/SpaceWorks/Resources/Fonts/README.md @@ -2,7 +2,7 @@ Gilroy Regular, Medium, Semibold and Bold are converted from the owner's supplied Design WOFF assets for app content, with Dynamic Type and explicit heavier faces for Bold Text. The owner requested these assets in the native apps; their inclusion remains subject to the applicable Gilroy license and does not grant an open-source redistribution license. Upstream names/outlines/metrics are preserved; the native container checksum necessarily changes. `Gilroy-provenance.json` records all source/native hashes. Every upstream OS/2 weight field is400, so the app selects the actual supplied PostScript face explicitly rather than relying on synthetic weight lookup. -Unbounded remains one short accent phrase per layout. Native navigation titles, system activities, security codes and identifiers retain native platform typography where needed. Gilroy is not substituted into the outlined logo masters. +Unbounded remains one short accent phrase per layout. Native navigation titles and system activities retain platform typography. IDs and secrets use Gilroy Medium with tabular digits, following the owner's no-monospace-family rule. Gilroy is not substituted into the outlined logo masters. `Unbounded-Variable.ttf` is the unmodified variable font outlines converted from the Design submodule's `fonts/Unbounded-Variable.woff2` container using fontTools 4.67.0 and Brotli. Its SIL Open Font License 1.1 is included alongside it; the font's family/PostScript names, glyphs and weight axis (200–900) are preserved. Conversion changes the web container into Apple's supported native TrueType container without renaming the font. diff --git a/SpaceWorksTests/NativePaymentsTests.swift b/SpaceWorksTests/NativePaymentsTests.swift index 147cbb4..3de6b5f 100644 --- a/SpaceWorksTests/NativePaymentsTests.swift +++ b/SpaceWorksTests/NativePaymentsTests.swift @@ -1,8 +1,20 @@ import XCTest import UIKit +import StripePaymentSheet @testable import SpaceWorks final class NativePaymentsTests: XCTestCase { + @MainActor func testStripeAppearanceReceivesUnscaledBrandBaseUnderBothContentSizeTraits() { + for category in [UIContentSizeCategory.large, .accessibilityExtraExtraExtraLarge] { + UITraitCollection(preferredContentSizeCategory: category).performAsCurrent { + let appearance = NativePayments.stripeAppearance() + XCTAssertEqual(appearance.font.base.fontName, "Gilroy-Regular") + XCTAssertEqual(appearance.font.base.pointSize, UIFont.systemFontSize) + XCTAssertEqual(appearance.font.sizeScaleFactor, 1) + } + } + } + func testSecretKeysAndIncompleteCustomerConfigurationNeverLaunchStripe() { XCTAssertFalse(NativePaymentInput.stripeIsValid(key: "sk_live_private", secret: "pi_1_secret_1", customer: nil, ephemeralKey: nil)) XCTAssertFalse(NativePaymentInput.stripeIsValid(key: "pk_test_public", secret: "", customer: nil, ephemeralKey: nil)) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4f26cc6..21c259d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,11 @@ # Phase notes +## B3 Brand conformance and supported checkout typography — implementation checkpoint (2026-10-18) + +The owner rule in the pinned brand specification requires IDs and secrets to use the UI family with tabular digits. Five remaining rows now use the actual Gilroy Medium face with digit-only tabular spacing; values, selection and privacy handling are unchanged. This corrects B1's retained monospace exception. The wide auth panel now uses the paired semantic surface/ink ground, so its adaptive supplied full-color/reversed master remains legible in both appearances; the former fixed citron ground selected a reversed cream logo in dark mode. No logo is recolored, redrawn or distorted. + +Stripe's pinned 26.14.0 public Appearance API now receives an unscaled registered Gilroy base. Its own font-size, weight-resolution and Dynamic Type behavior remain in control, with the existing colors, shapes and checkout authority/callback semantics preserved. The supplied app font tables are unchanged; no promise is made that the SDK resolves all four separately named app faces. Razorpay's existing supported theme color remains unchanged; no unsupported font key is invented. One authored native test inspects the actual Stripe appearance under default and accessibility traits to guard against double scaling. Intended totals are 299 native/36 UI/42 Python; exact Apple compilation/runtime, native SDK configuration and full current-source visual/Release proof remain pending. The Debug metadata writer now explicitly uses UTF-8. + ## B2 Native orientation evidence and compact action framing — diagnostic checkpoint (2026-10-18) The exact 3d3 compact recording showed slow short reveal gestures leave the two actions at alternating positions or with the primary control's upper edge behind the fixed header. The test now targets the center of their combined frame within the actual visible viewport, using its available interior slack. The existing 24-attempt limit, full-control bounds, enabled/hittable foreground checks, clear gutter, capped slow gesture/end hold, far native swipes and all outcomes remain unchanged. No app reachability or UIKit threshold cause is claimed. diff --git a/docs/WHITE-LABEL.md b/docs/WHITE-LABEL.md index dd42520..ebc8896 100644 --- a/docs/WHITE-LABEL.md +++ b/docs/WHITE-LABEL.md @@ -22,7 +22,7 @@ Optional deployment links and artwork are configured in the same `branding/brand Run the generator tests as well as generation after changing branding. Test a fresh build with your actual name, icon, palette, artwork, support/privacy links and bundle identifier; inspect the installed app and the Release bundle rather than only the JSON configuration. The automated fixture tests prove generator behavior with temporary inputs; they do not validate your deployment pages, signing or store submission. -App content uses the owner's supplied Gilroy native faces with Dynamic Type and Bold Text support. Native bars/system activities and monospaced security identifiers retain platform conventions. Gilroy and the Unbounded accent font conversion provenance are in `SpaceWorks/Resources/Fonts/`; upstream licenses still apply and this repository grants no new font rights. Brand assets retain their upstream licenses; this repo does not grant additional embedding rights. Keep master logos static, undistorted and fully opaque with the Design clear space; motion belongs on sibling content and respects Reduce Motion. +App content uses the owner's supplied Gilroy native faces with Dynamic Type and Bold Text support. Native bars/system activities retain platform conventions; IDs and secrets use Gilroy with tabular digits, not a monospace family. Gilroy and the Unbounded accent font conversion provenance are in `SpaceWorks/Resources/Fonts/`; upstream licenses still apply and this repository grants no new font rights. Brand assets retain their upstream licenses; this repo does not grant additional embedding rights. Keep master logos static, undistorted and fully opaque with the Design clear space; motion belongs on sibling content and respects Reduce Motion. Register the real Google iOS OAuth audience and optional `google_reversed_client_id` callback scheme. Enable Sign in with Apple for the registered bundle/team. Organization sign-in needs its actual native callback/device-grant contract. Compiled SDK dependencies do not enable a web-only server flow automatically. Never substitute a browser cookie or access-only response for a complete native grant. From 34bb9793d47926a9ced5f79b64750d1e433bafda Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:46:33 +0530 Subject: [PATCH 65/98] feat(b4): add owned native feedback and accessible onboarding motion Co-Authored-By: Codex --- SpaceWorks/AccountAccessState.swift | 42 ++++--- SpaceWorks/AccountAccessView.swift | 3 +- SpaceWorks/AuthState.swift | 27 ++++- SpaceWorks/AuthView.swift | 1 + SpaceWorks/IntroductionView.swift | 10 +- SpaceWorks/MemberActionViews.swift | 2 +- SpaceWorks/MemberActionsState.swift | 6 +- SpaceWorks/Platform/NativeFeedback.swift | 74 ++++++++++++ SpaceWorks/StaffCheckInState.swift | 9 +- SpaceWorks/StaffHandoverState.swift | 5 +- SpaceWorks/StaffHandoverView.swift | 1 + SpaceWorks/StaffViews.swift | 1 + SpaceWorksTests/AuthTests.swift | 7 +- SpaceWorksTests/EmailOwnerTests.swift | 5 +- SpaceWorksTests/MemberActionsTests.swift | 4 + SpaceWorksTests/NativeFeedbackTests.swift | 65 +++++++++++ SpaceWorksTests/StaffTests.swift | 7 ++ SpaceWorksUITests/MainFlowTests.swift | 133 ++++++++++++---------- docs/CHANGELOG.md | 10 ++ 19 files changed, 316 insertions(+), 96 deletions(-) create mode 100644 SpaceWorks/Platform/NativeFeedback.swift create mode 100644 SpaceWorksTests/NativeFeedbackTests.swift diff --git a/SpaceWorks/AccountAccessState.swift b/SpaceWorks/AccountAccessState.swift index 8afa123..35f389a 100644 --- a/SpaceWorks/AccountAccessState.swift +++ b/SpaceWorks/AccountAccessState.swift @@ -17,6 +17,7 @@ final class AccountAccessCooldown { @MainActor @Observable final class AccountAccessState: Identifiable { let id = UUID() + private(set) var feedback: NativeFeedbackEvent? let isDemo: Bool enum Step: Equatable { case signUp, created, requestReset, confirmReset, resetComplete, unavailable, emailVerification } var step: Step @@ -45,6 +46,7 @@ final class AccountAccessState: Identifiable { private let resendVerification: () async throws -> Void private let now: () -> Date private let ownerIsCurrent: () -> Bool + private var feedbackGeneration = 0 init(step: Step, now: @escaping () -> Date = Date.init, cooldown: AccountAccessCooldown? = nil, isDemo: Bool = false, create: @escaping (String, String, String, String) async throws -> Void, @@ -61,20 +63,20 @@ final class AccountAccessState: Identifiable { func signUp() async { guard !busy, now() >= submitAfter else { return } let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, trimmed.count <= 200 else { error = String(localized: "Enter your name (up to 200 characters)."); return } - guard validEmail else { error = String(localized: "Enter a valid email address."); return } + guard !trimmed.isEmpty, trimmed.count <= 200 else { reject(String(localized: "Enter your name (up to 200 characters).")); return } + guard validEmail else { reject(String(localized: "Enter a valid email address.")); return } guard validatePasswords() else { return } - guard password.count <= 128 else { error = String(localized: "Use a password with no more than 128 characters."); return } - guard phone.count <= 32 else { error = String(localized: "Use a phone number with no more than 32 characters."); return } - await perform { + guard password.count <= 128 else { reject(String(localized: "Use a password with no more than 128 characters.")); return } + guard phone.count <= 32 else { reject(String(localized: "Use a phone number with no more than 32 characters.")); return } + await perform(feedback: .success) { try await self.create(trimmed, self.email.trimmingCharacters(in: .whitespacesAndNewlines), self.password, self.phone) self.clearSecrets(); self.step = .created } } func requestCode(resending: Bool = false) async { guard !busy, now() >= resendAfter, now() >= submitAfter else { return } - guard validEmail else { error = String(localized: "Enter a valid email address."); return } - await perform { + guard validEmail else { reject(String(localized: "Enter a valid email address.")); return } + await perform(feedback: .selection) { try await self.request(self.email.trimmingCharacters(in: .whitespacesAndNewlines)) self.resendAfter = self.now().addingTimeInterval(60) self.notice = String(localized: "If an account matches this email, a recovery code will arrive. Delivery may take a few minutes.") @@ -83,23 +85,26 @@ final class AccountAccessState: Identifiable { } func confirmReset() async { guard !busy, now() >= submitAfter else { return } - guard isSixDigits else { error = String(localized: "Enter the six-digit verification code."); return } + guard isSixDigits else { reject(String(localized: "Enter the six-digit verification code.")); return } guard validatePasswords() else { return } + let generation = feedbackGeneration busy = true; error = nil defer { busy = false } do { try await reset(email.trimmingCharacters(in: .whitespacesAndNewlines), code, password) clearSecrets(); step = .resetComplete + if generation == feedbackGeneration, ownerIsCurrent() { feedback = .init(.success) } } catch { // Every server rejection is identical: don't expose an OTP/account-existence oracle. self.error = String(localized: "Invalid or expired verification code.") preserveCooldown(error) + if generation == feedbackGeneration, ownerIsCurrent(), !APIError.isCancellation(error) { feedback = .init(.error) } } } func confirmEmail() async { guard !busy, now() >= submitAfter else { return } - guard isSixDigits else { error = String(localized: "Enter the six-digit verification code."); return } - await perform { + guard isSixDigits else { reject(String(localized: "Enter the six-digit verification code.")); return } + await perform(feedback: .success) { try await self.verify(self.code) guard self.ownerIsCurrent() else { throw CancellationError() } self.code = ""; self.notice = String(localized: "Email confirmed.") @@ -107,7 +112,7 @@ final class AccountAccessState: Identifiable { } func resendEmail() async { guard canResend, now() >= submitAfter else { return } - await perform { + await perform(feedback: .selection) { try await self.resendVerification() guard self.ownerIsCurrent() else { throw CancellationError() } self.resendAfter = self.now().addingTimeInterval(60) @@ -119,15 +124,17 @@ final class AccountAccessState: Identifiable { clearSecrets(); error = nil; notice = nil; step = .requestReset // Operation cooldown deliberately survives editing and navigation. } + func retireFeedback() { feedbackGeneration += 1; feedback = nil } func clearSecrets() { password = ""; confirmation = ""; code = "" } + private func reject(_ message: String) { error = message; feedback = .init(.error) } private var isSixDigits: Bool { code.count == 6 && code.allSatisfy { $0.isASCII && $0.isNumber } } private var validEmail: Bool { let value = email.trimmingCharacters(in: .whitespacesAndNewlines) return value.count <= 254 && value.range(of: #"^[^\s@]+@[^\s@]+\.[^\s@]+$"#, options: .regularExpression) != nil } private func validatePasswords() -> Bool { - guard password.count >= 8 else { error = String(localized: "Use at least 8 characters."); return false } - guard password == confirmation else { error = String(localized: "Passwords don't match."); return false } + guard password.count >= 8 else { reject(String(localized: "Use at least 8 characters.")); return false } + guard password == confirmation else { reject(String(localized: "Passwords don't match.")); return false } return true } private func preserveCooldown(_ failure: Error) { @@ -135,11 +142,16 @@ final class AccountAccessState: Identifiable { submitAfter = max(submitAfter, deadline); resendAfter = max(resendAfter, deadline) } } - private func perform(_ operation: () async throws -> Void) async { + private func perform(feedback kind: NativeFeedbackEvent.Kind, _ operation: () async throws -> Void) async { + let generation = feedbackGeneration busy = true; error = nil defer { busy = false } - do { try await operation() } catch { + do { + try await operation() + if generation == feedbackGeneration, ownerIsCurrent() { feedback = .init(kind) } + } catch { guard ownerIsCurrent() else { return } + if generation == feedbackGeneration, !APIError.isCancellation(error) { feedback = .init(.error) } preserveCooldown(error) if APIError.code(error) == "recovery_unavailable" { step = .unavailable; clearSecrets() } else { self.error = APIError.display(error) } diff --git a/SpaceWorks/AccountAccessView.swift b/SpaceWorks/AccountAccessView.swift index db9a3a7..06a6610 100644 --- a/SpaceWorks/AccountAccessView.swift +++ b/SpaceWorks/AccountAccessView.swift @@ -66,7 +66,8 @@ struct AccountAccessView: View { } } .interactiveDismissDisabled(form.busy) - .onDisappear { form.clearSecrets() } + .nativeFeedback(form.feedback) + .onDisappear { form.retireFeedback(); form.clearSecrets() } } private var heading: String { switch form.step { diff --git a/SpaceWorks/AuthState.swift b/SpaceWorks/AuthState.swift index 0aeac79..6bed4a7 100644 --- a/SpaceWorks/AuthState.swift +++ b/SpaceWorks/AuthState.swift @@ -6,6 +6,7 @@ import SpaceWorksAPI final class AuthState { enum Stage: Equatable { case restoring, signIn, mfa, recoveryCodes, passwordChange, securityCompletion, signedIn } private(set) var stage: Stage = .restoring + private(set) var feedback: NativeFeedbackEvent? var username = "" var password = "" var code = "" @@ -150,8 +151,9 @@ final class AuthState { func signIn() async { guard !busy, (retryAfter ?? .distantPast) <= Date() else { return } guard !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !password.isEmpty else { - error = String(localized: "Enter your email or username and password."); return + error = String(localized: "Enter your email or username and password."); feedback = .init(.error); return } + let feedbackGeneration = identityGeneration busy = true; error = nil defer { busy = false; password = "" } do { @@ -165,20 +167,26 @@ final class AuthState { switch result { case .DeviceTokenResponse(let tokens): try await accept(TokenPair(access: tokens.access, refresh: tokens.refresh)) + await authenticationFeedback(generation: feedbackGeneration) case .MFAChallengeResponse(let challenge): mfa = challenge mfaExpires = Date().addingTimeInterval(TimeInterval(challenge.expires_in)) if challenge.enrollment_required { enrollment = try await service.enroll(challenge: challenge.challenge) } stage = .mfa + if identityGeneration == feedbackGeneration { feedback = .init(.selection) } } - } catch { fail(error) } + } catch { + fail(error) + if identityGeneration == feedbackGeneration, !APIError.isCancellation(error) { feedback = .init(.error) } + } } func verify() async { guard !busy, let mfa, (retryAfter ?? .distantPast) <= Date() else { return } guard (mfaExpires ?? .distantPast) > Date() else { backToSignIn(); error = String(localized: "Your sign-in expired. Try again."); return } - guard code.count >= 6 else { error = String(localized: "Enter your authenticator or recovery code."); return } + guard code.count >= 6 else { error = String(localized: "Enter your authenticator or recovery code."); feedback = .init(.error); return } + let feedbackGeneration = identityGeneration busy = true; error = nil defer { busy = false; code = "" } do { @@ -192,7 +200,11 @@ final class AuthState { recoveryCodes = result.recovery_codes ?? [] try await accept(TokenPair(access: result.access, refresh: refresh)) if !recoveryCodes.isEmpty { stage = .recoveryCodes } - } catch { fail(error) } + await authenticationFeedback(generation: feedbackGeneration) + } catch { + fail(error) + if identityGeneration == feedbackGeneration, !APIError.isCancellation(error) { feedback = .init(.error) } + } } func confirmRecoveryCodes() { recoveryCodes = []; stage = user?.must_change_password == true ? .passwordChange : .signedIn } func backToSignIn() { @@ -340,6 +352,12 @@ final class AuthState { if isUnauthorized(error) { await invalidateSession(message: APIError.display(error)) } } } + private func authenticationFeedback(generation: Int) async { + guard identityGeneration == generation, let credentials = acceptedCredentials else { return } + guard await service.refresh.isCurrent(credentials), identityGeneration == generation, + acceptedCredentials != nil, user != nil else { return } + feedback = .init(stage == .signedIn ? .success : .selection) + } private func accept(_ pair: TokenPair) async throws { guard !pair.access.isEmpty, !pair.refresh.isEmpty else { throw APIError(status: 0, code: "native_session_missing", detail: String(localized: "This server did not provide a device session. Contact your makerspace.")) @@ -489,6 +507,7 @@ final class AuthState { for clear in holders.values { clear() } } private func clearLocalIdentity() { + feedback = nil identityGeneration += 1; identityFlight?.cancel(); identityFlight = nil; clearPrivateHolders() settings.removeObject(forKey: selectionKey) locallyLeftMemberships = []; securityMessage = nil; acceptedCredentials = nil diff --git a/SpaceWorks/AuthView.swift b/SpaceWorks/AuthView.swift index 90c191d..d803f26 100644 --- a/SpaceWorks/AuthView.swift +++ b/SpaceWorks/AuthView.swift @@ -32,6 +32,7 @@ struct AuthRoot: View { } } } + .nativeFeedback(auth.feedback) .task { await auth.restore(); bindRouting() } .onChange(of: auth.stage) { _, _ in bindRouting() } .onChange(of: auth.user?.id) { _, _ in bindRouting() } diff --git a/SpaceWorks/IntroductionView.swift b/SpaceWorks/IntroductionView.swift index bda3398..aa0af32 100644 --- a/SpaceWorks/IntroductionView.swift +++ b/SpaceWorks/IntroductionView.swift @@ -31,7 +31,7 @@ struct IntroductionView: View { HStack { Text("\(page + 1) of 3").foregroundStyle(Color.swMuted).accessibilityLabel("Introduction, page \(page + 1) of 3") Spacer() - Button("Skip introduction", action: finish).frame(minHeight: 48) + Button("Skip introduction") { NativeFeedback.play(.success); finish() }.frame(minHeight: 48) } VStack(alignment: .leading, spacing: 24) { if geometry.size.height >= 650 && !typeSize.isAccessibilitySize { @@ -45,13 +45,13 @@ struct IntroductionView: View { } Text(headings[page]).brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) Text(details[page]).brandFont(.title3).foregroundStyle(Color.swMuted) - }.id(page).transition(.opacity) + }.id(page).transition(reduceMotion ? .identity : .opacity.combined(with: .offset(y: 8))) Spacer(minLength: 16) Button(page == 2 ? "Get started" : "Next") { - if page == 2 { finish() } - else { withAnimation(reduceMotion ? nil : MobileMotion.expandAnimation) { page += 1 } } + if page == 2 { NativeFeedback.play(.success); finish() } + else { NativeFeedback.play(.selection); withAnimation(reduceMotion ? nil : MobileMotion.expandAnimation) { page += 1 } } }.buttonStyle(SWPrimary()).accessibilityIdentifier("intro-next") - Button("Back") { withAnimation(reduceMotion ? nil : MobileMotion.expandAnimation) { page -= 1 } } + Button("Back") { NativeFeedback.play(.selection); withAnimation(reduceMotion ? nil : MobileMotion.expandAnimation) { page -= 1 } } .frame(minHeight: 48).opacity(page == 0 ? 0 : 1) .disabled(page == 0).accessibilityHidden(page == 0) }.padding(24).frame(maxWidth: 600).frame(minHeight: geometry.size.height, alignment: .topLeading) diff --git a/SpaceWorks/MemberActionViews.swift b/SpaceWorks/MemberActionViews.swift index b9ecefe..8e49102 100644 --- a/SpaceWorks/MemberActionViews.swift +++ b/SpaceWorks/MemberActionViews.swift @@ -51,7 +51,7 @@ struct RequestCartView: View { }.padding(24).frame(maxWidth: 720, alignment: .leading).frame(maxWidth: .infinity).disabled(state.busy) }.background(Color.swBg).scrollDismissesKeyboard(.interactively).accessibilityIdentifier("request-review-scroll") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close") { dismiss() }.frame(minHeight: 48).disabled(state.busy) } } - }.interactiveDismissDisabled(state.busy) + }.interactiveDismissDisabled(state.busy).nativeFeedback(state.feedback) } @ViewBuilder private func submission(at date: Date) -> some View { if let deadline = state.retryAfter, date < deadline { Text(CooldownCopy.retryMessage(until: deadline, now: date)).foregroundStyle(Color.swMuted) } diff --git a/SpaceWorks/MemberActionsState.swift b/SpaceWorks/MemberActionsState.swift index 00206a6..1979666 100644 --- a/SpaceWorks/MemberActionsState.swift +++ b/SpaceWorks/MemberActionsState.swift @@ -33,6 +33,7 @@ final class RequestCartState { private(set) var error: String? private(set) var retryAfter: Date? private(set) var attemptKey = UUID().uuidString + private(set) var feedback: NativeFeedbackEvent? private var generation = 0 private var active = true let repository: any MemberActionsRepository @@ -65,12 +66,13 @@ final class RequestCartState { let result = try await repository.submit(items: items, purpose: purpose.trimmingCharacters(in: .whitespacesAndNewlines), key: attemptKey) guard active, generation == expected else { return } lines = []; purpose = ""; receipt = result - attemptKey = UUID().uuidString; retryAfter = nil + attemptKey = UUID().uuidString; retryAfter = nil; feedback = .init(.success) } catch { guard active, generation == expected else { return } self.error = APIError.display(error); retryAfter = APIError.retryDeadline(error) + if !APIError.isCancellation(error) { feedback = .init(.error) } } } - func invalidate() { active = false; generation += 1; lines = []; purpose = ""; receipt = nil; error = nil; retryAfter = nil } + func invalidate() { feedback = nil; active = false; generation += 1; lines = []; purpose = ""; receipt = nil; error = nil; retryAfter = nil } private func changed() { generation += 1; attemptKey = UUID().uuidString; receipt = nil; error = nil } } diff --git a/SpaceWorks/Platform/NativeFeedback.swift b/SpaceWorks/Platform/NativeFeedback.swift new file mode 100644 index 0000000..f84088b --- /dev/null +++ b/SpaceWorks/Platform/NativeFeedback.swift @@ -0,0 +1,74 @@ +import SwiftUI +import UIKit + +/// A fresh, owned operation result. Merely displaying existing state never creates an event. +struct NativeFeedbackEvent: Equatable { + enum Kind: Equatable { case selection, success, error } + let id = UUID() + let kind: Kind + let activation: UUID? + @MainActor init(_ kind: Kind) { + self.kind = kind; activation = NativeFeedbackLifecycle.shared.activation + } + @MainActor init(_ kind: Kind, lifecycle: NativeFeedbackLifecycle) { + self.kind = kind; activation = lifecycle.activation + } +} + +/// Retire feedback before background rendering can be deferred until a later activation. +@MainActor final class NativeFeedbackLifecycle { + static let shared = NativeFeedbackLifecycle(initiallyActive: UIApplication.shared.applicationState == .active) + private(set) var activation: UUID? + private let notifications: NotificationCenter + private var observers: [NSObjectProtocol] = [] + init(initiallyActive: Bool, notifications: NotificationCenter = .default) { + self.notifications = notifications + activation = initiallyActive ? UUID() : nil + observers.append(notifications.addObserver(forName: UIApplication.willResignActiveNotification, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.activation = nil } + }) + observers.append(notifications.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.activation = UUID() } + }) + } + func accepts(_ event: NativeFeedbackEvent) -> Bool { + guard let activation else { return false } + return event.activation == activation + } + func stop() { + for observer in observers { notifications.removeObserver(observer) } + observers = []; activation = nil + } + deinit { for observer in observers { notifications.removeObserver(observer) } } +} + +@MainActor enum NativeFeedback { + static func play(_ kind: NativeFeedbackEvent.Kind) { + guard UIApplication.shared.applicationState == .active else { return } + switch kind { + case .selection: UISelectionFeedbackGenerator().selectionChanged() + case .success: UINotificationFeedbackGenerator().notificationOccurred(.success) + case .error: UINotificationFeedbackGenerator().notificationOccurred(.error) + } + } +} + +private struct NativeFeedbackModifier: ViewModifier { + let event: NativeFeedbackEvent? + @Environment(\.scenePhase) private var phase + func body(content: Content) -> some View { + content.onChange(of: event) { old, new in + // No initial emission. The activation check also rejects a callback whose + // rendering was deferred across background/foreground, even if phase is active. + guard phase == .active, let new, old != new, + NativeFeedbackLifecycle.shared.accepts(new) else { return } + NativeFeedback.play(new.kind) + } + } +} + +extension View { + func nativeFeedback(_ event: NativeFeedbackEvent?) -> some View { + modifier(NativeFeedbackModifier(event: event)) + } +} diff --git a/SpaceWorks/StaffCheckInState.swift b/SpaceWorks/StaffCheckInState.swift index 96e10b3..1a4c76d 100644 --- a/SpaceWorks/StaffCheckInState.swift +++ b/SpaceWorks/StaffCheckInState.swift @@ -5,6 +5,7 @@ import SpaceWorksAPI @MainActor @Observable final class StaffCheckInState { let parent: StaffState + private(set) var feedback: NativeFeedbackEvent? private let eventID: Int private let eventMakerspaceID: Int private(set) var eventTitle: String @@ -19,7 +20,7 @@ final class StaffCheckInState { parent.registerChild { [weak self] in self?.invalidate() } } var canConfirm: Bool { active && parent.canRequest && parent.can("manage_events") && !busy && person?.status == "registered" && person?.confirmable == true } - func invalidate() { active = false; generation += 1; person = nil; confirmation = nil; error = nil; busy = false; eventTitle = "" } + func invalidate() { feedback = nil; active = false; generation += 1; person = nil; confirmation = nil; error = nil; busy = false; eventTitle = "" } func resolve(_ token: String) async { guard active, parent.canRequest, parent.can("manage_events"), !busy, eventMakerspaceID == parent.makerspaceID else { return } generation += 1; let current = generation; person = nil; confirmation = nil; error = nil @@ -28,9 +29,10 @@ final class StaffCheckInState { busy = true do { let resolved = try await parent.repository.resolveCheckIn(eventID: eventID, body: .init(checkin_token: value)) - guard active, current == generation else { return }; person = resolved; busy = false + guard active, current == generation else { return }; person = resolved; busy = false; feedback = .init(.selection) } catch { guard active, current == generation else { return }; busy = false; parent.refuse(error) + if active, current == generation, !APIError.isCancellation(error) { feedback = .init(.error) } self.error = APIError.status(error) == 404 ? String(localized: "That is not a check-in code for this event.") : APIError.display(error) } } @@ -41,9 +43,10 @@ final class StaffCheckInState { do { _ = try await parent.repository.markAttended(registrationID: person.registration_id, body: .init(source: .init(value1: .qr))) guard active, current == generation else { return } - confirmation = String(localized: "Attendance recorded for \(person.name)."); self.person = nil; busy = false + confirmation = String(localized: "Attendance recorded for \(person.name)."); self.person = nil; busy = false; feedback = .init(.success) } catch { guard active, current == generation else { return }; self.person = nil; busy = false; parent.refuse(error) + if active, current == generation, !APIError.isCancellation(error) { feedback = .init(.error) } self.error = APIError.status(error) == 409 ? String(localized: "This registration changed. Scan the code again to check its latest status.") : APIError.display(error) } } diff --git a/SpaceWorks/StaffHandoverState.swift b/SpaceWorks/StaffHandoverState.swift index b200e0e..edf8855 100644 --- a/SpaceWorks/StaffHandoverState.swift +++ b/SpaceWorks/StaffHandoverState.swift @@ -64,6 +64,7 @@ struct StaffReturnDraft { @MainActor @Observable final class StaffHandoverState { private(set) var mode: StaffHandoverMode + private(set) var feedback: NativeFeedbackEvent? let parent: StaffState var step = 0 var borrowerID: Int? @@ -161,7 +162,7 @@ final class StaffHandoverState { if case .directIssue = mode { return members.contains { $0.user_id == borrowerID } } return borrowerChecked } - func invalidate() { active = false; generation += 1; photo = nil; evidenceID = nil; scans = [:]; members = []; products = []; returns = []; receipt = nil; error = nil; mode = .directIssue; boxCode = ""; boxLabel = ""; remark = ""; borrowerID = nil; kits = []; kitCounts = [:] } + func invalidate() { feedback = nil; active = false; generation += 1; photo = nil; evidenceID = nil; scans = [:]; members = []; products = []; returns = []; receipt = nil; error = nil; mode = .directIssue; boxCode = ""; boxLabel = ""; remark = ""; borrowerID = nil; kits = []; kitCounts = [:] } func loadBorrowers(more: Bool = false) async { guard canRequest, !more || membersNext != nil else { return }; busy = true let page = more ? membersNext! : 1 @@ -277,9 +278,11 @@ final class StaffHandoverState { } guard active else { return }; receipt = "\(mode.isReturn ? "Return" : "Issue") #\(id) recorded" notice = String(localized: "Evidence photo #\(evidenceID) is attached."); busy = false; step = steps.count - 1 + feedback = .init(.success) await parent.loadQueue(); await parent.loadDirect() } catch { guard active else { return }; needsReload = true; fail(error) + if active, !APIError.isCancellation(error) { feedback = .init(.error) } if assignmentRecorded { notice = String(localized: "Box assignment was recorded; issue still needs to complete. Check the latest request before retrying.") } } } diff --git a/SpaceWorks/StaffHandoverView.swift b/SpaceWorks/StaffHandoverView.swift index 1f22348..dedd26a 100644 --- a/SpaceWorks/StaffHandoverView.swift +++ b/SpaceWorks/StaffHandoverView.swift @@ -52,6 +52,7 @@ struct StaffHandoverView: View { }.padding(24).frame(maxWidth: 760).frame(maxWidth: .infinity) }.background(Color.swBg).foregroundStyle(Color.swInk).tint(Color.swAccentInk) .navigationTitle(state.mode.title).navigationBarTitleDisplayMode(.inline) + .nativeFeedback(state.feedback) .interactiveDismissDisabled(state.busy) .navigationBarBackButtonHidden(state.busy) .task { diff --git a/SpaceWorks/StaffViews.swift b/SpaceWorks/StaffViews.swift index d12510f..3ab0da3 100644 --- a/SpaceWorks/StaffViews.swift +++ b/SpaceWorks/StaffViews.swift @@ -191,6 +191,7 @@ private struct StaffCheckInView: View { } } }.padding(24).frame(maxWidth: 720).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle("Check-in").navigationBarTitleDisplayMode(.inline) + .nativeFeedback(state.feedback) .sheet(isPresented: $scanner) { NativeQRScanner(title: String(localized: "Check-in code")) { value in scanner = false; Task { await state.resolve(value) } } } } } diff --git a/SpaceWorksTests/AuthTests.swift b/SpaceWorksTests/AuthTests.swift index 79a991a..68d83da 100644 --- a/SpaceWorksTests/AuthTests.swift +++ b/SpaceWorksTests/AuthTests.swift @@ -54,16 +54,19 @@ final class MemoryTokens: TokenStorage, @unchecked Sendable { func testPasswordSignInPersistsBeforeIdentitySelectsAndLogsOut() async throws { let storage = MemoryTokens() let auth = state(storage: storage, handler: response) - await auth.restore(); XCTAssertEqual(auth.stage, .signIn) + await auth.restore(); XCTAssertEqual(auth.stage, .signIn); XCTAssertNil(auth.feedback) auth.username = " maker "; auth.password = "password-fixture" await auth.signIn() XCTAssertEqual(auth.stage, .signedIn); XCTAssertNotNil(auth.acceptedCredentials) + XCTAssertEqual(auth.feedback?.kind, .success) + let feedback = auth.feedback + await auth.foreground(); XCTAssertEqual(auth.feedback, feedback, "Identity refresh must remain quiet") XCTAssertEqual(try storage.load(), TokenPair(access: "test-access", refresh: "test-refresh")) XCTAssertEqual(auth.memberships.count, 2); XCTAssertNil(auth.selectedID) await auth.select(7); XCTAssertEqual(auth.selectedID, 7) XCTAssertEqual(auth.password, ""); XCTAssertFalse(auth.staffToolsAvailable) await auth.signOut(); XCTAssertNil(try storage.load()); XCTAssertNil(auth.user); XCTAssertNil(auth.acceptedCredentials) - XCTAssertEqual(auth.stage, .signIn) + XCTAssertEqual(auth.stage, .signIn); XCTAssertNil(auth.feedback) } func testFailureCallbackChecksAcceptedIdentityAfterMainActorHop() async throws { let probe = OwnedRepositoryProbe() diff --git a/SpaceWorksTests/EmailOwnerTests.swift b/SpaceWorksTests/EmailOwnerTests.swift index 5670730..509bb43 100644 --- a/SpaceWorksTests/EmailOwnerTests.swift +++ b/SpaceWorksTests/EmailOwnerTests.swift @@ -27,7 +27,7 @@ import SpaceWorksAPI await replace(auth) let baseline = server.requests.count await old.confirmEmail(); await old.resendEmail() - XCTAssertEqual(server.requests.count, baseline); XCTAssertNil(old.notice) + XCTAssertEqual(server.requests.count, baseline); XCTAssertNil(old.notice); XCTAssertNil(old.feedback) XCTAssertTrue(auth.accountForm(.emailVerification).canResend) XCTAssertEqual(auth.selectedID, 8); XCTAssertEqual(auth.stage, .signedIn) await auth.invalidateSession(message: "Test finished.") @@ -50,7 +50,7 @@ import SpaceWorksAPI let baseline = server.requests.count server.release(); await request.value XCTAssertEqual(server.requests.count, baseline, "An old acknowledgement must not issue a new-owner /me") - XCTAssertNil(old.notice); XCTAssertTrue(auth.accountForm(.emailVerification).canResend) + XCTAssertNil(old.notice); XCTAssertNil(old.feedback); XCTAssertTrue(auth.accountForm(.emailVerification).canResend) XCTAssertEqual(auth.stage, .signedIn); XCTAssertEqual(auth.selectedID, 8) let pair = await auth.service.refresh.current(); XCTAssertEqual(pair?.access, "new-access") await auth.invalidateSession(message: "Test finished.") @@ -61,6 +61,7 @@ import SpaceWorksAPI let form = auth.accountForm(.emailVerification); form.code = "123456" await form.confirmEmail() XCTAssertEqual(form.notice, "Email confirmed."); XCTAssertNil(form.error) + XCTAssertEqual(form.feedback?.kind, .success) await form.resendEmail(); XCTAssertNil(form.error); XCTAssertFalse(form.canResend) let calls = server.requests XCTAssertEqual(calls.filter { $0.path == "/api/v1/auth/device/refresh" }.count, 1) diff --git a/SpaceWorksTests/MemberActionsTests.swift b/SpaceWorksTests/MemberActionsTests.swift index b5be147..1435d55 100644 --- a/SpaceWorksTests/MemberActionsTests.swift +++ b/SpaceWorksTests/MemberActionsTests.swift @@ -7,12 +7,16 @@ import SpaceWorksAPI let repository = DemoMemberActionsRepository(scenario: .refused) let state = RequestCartState(repository: repository) state.add(try await DemoMemberRepository().product(1)) + XCTAssertNil(state.feedback) let first = state.attemptKey await state.submit(); XCTAssertEqual(state.attemptKey, first); XCTAssertNil(state.receipt); XCTAssertEqual(state.lines.count, 1) + XCTAssertEqual(state.feedback?.kind, .error) state.purpose = "Make a circuit"; XCTAssertNotEqual(state.attemptKey, first) let second = state.attemptKey; await state.submit(); XCTAssertEqual(state.attemptKey, second) await repository.preview(.success); await state.submit() XCTAssertEqual(state.receipt?.status, "pending"); XCTAssertTrue(state.lines.isEmpty); XCTAssertNotEqual(state.attemptKey, second) + XCTAssertEqual(state.feedback?.kind, .success) + state.invalidate(); await state.submit(); XCTAssertNil(state.feedback) } func testDuplicateDemoRequestKeyReturnsSameReceiptAndOnlyOneHistoryRow() async throws { let store = DemoMemberStore(); let repository = DemoMemberActionsRepository(store: store) diff --git a/SpaceWorksTests/NativeFeedbackTests.swift b/SpaceWorksTests/NativeFeedbackTests.swift new file mode 100644 index 0000000..aaa82c2 --- /dev/null +++ b/SpaceWorksTests/NativeFeedbackTests.swift @@ -0,0 +1,65 @@ +import XCTest +import UIKit +@testable import SpaceWorks + +@MainActor final class NativeFeedbackTests: XCTestCase { + func testNativeActivationNotificationsRejectDeferredAndBackgroundFeedback() { + let notifications = NotificationCenter() + let lifecycle = NativeFeedbackLifecycle(initiallyActive: true, notifications: notifications) + defer { lifecycle.stop() } + let foreground = NativeFeedbackEvent(.success, lifecycle: lifecycle) + XCTAssertTrue(lifecycle.accepts(foreground)) + notifications.post(name: UIApplication.willResignActiveNotification, object: nil) + let backgroundCompletion = NativeFeedbackEvent(.success, lifecycle: lifecycle) + XCTAssertFalse(lifecycle.accepts(foreground)); XCTAssertFalse(lifecycle.accepts(backgroundCompletion)) + notifications.post(name: UIApplication.didBecomeActiveNotification, object: nil) + // Model a SwiftUI callback deferred until the next active scene. Neither old event replays. + XCTAssertFalse(lifecycle.accepts(foreground)); XCTAssertFalse(lifecycle.accepts(backgroundCompletion)) + XCTAssertTrue(lifecycle.accepts(NativeFeedbackEvent(.selection, lifecycle: lifecycle))) + lifecycle.stop() + XCTAssertFalse(lifecycle.accepts(NativeFeedbackEvent(.success, lifecycle: lifecycle))) + } + + func testClosingFormRetiresInFlightFeedbackWhileFreshAcknowledgementStillEmits() async throws { + let gate = FeedbackOperationGate() + defer { Task { await gate.release() } } + let form = AccountAccessState(step: .signUp, create: { _, _, _, _ in await gate.suspend() }, + request: { _ in }, reset: { _, _, _ in }) + form.name = "Sample Maker"; form.email = "maker@example.test" + form.password = "sample-password"; form.confirmation = form.password + let operation = Task { await form.signUp() } + for _ in 0..<500 { + if await gate.started { break } + try await Task.sleep(nanoseconds: 10_000_000) + } + let started = await gate.started + form.retireFeedback() + await gate.release(); await operation.value + XCTAssertTrue(started); XCTAssertEqual(form.step, .created); XCTAssertNil(form.feedback) + let fresh = AccountAccessState(step: .signUp, create: { _, _, _, _ in }, request: { _ in }, reset: { _, _, _ in }) + fresh.name = "Sample Maker"; fresh.email = "maker@example.test" + fresh.password = "sample-password"; fresh.confirmation = fresh.password + await fresh.signUp(); XCTAssertEqual(fresh.feedback?.kind, .success) + } + + func testCancellationIsQuietAndExplicitValidationHasNoTransportSideEffect() async { + var requests = 0 + let form = AccountAccessState(step: .requestReset, create: { _, _, _, _ in }, request: { _ in + requests += 1; throw CancellationError() + }, reset: { _, _, _ in }) + await form.requestCode(); XCTAssertEqual(form.feedback?.kind, .error); XCTAssertEqual(requests, 0) + form.retireFeedback(); form.email = "maker@example.test" + await form.requestCode(); XCTAssertEqual(requests, 1); XCTAssertNil(form.feedback) + } +} + +private actor FeedbackOperationGate { + private(set) var started = false + private var open = false + private var continuation: CheckedContinuation? + func suspend() async { + started = true + if !open { await withCheckedContinuation { continuation = $0 } } + } + func release() { open = true; continuation?.resume(); continuation = nil } +} diff --git a/SpaceWorksTests/StaffTests.swift b/SpaceWorksTests/StaffTests.swift index 6cf8742..7ce8eeb 100644 --- a/SpaceWorksTests/StaffTests.swift +++ b/SpaceWorksTests/StaffTests.swift @@ -147,6 +147,9 @@ private final class StaffObservationChanges: @unchecked Sendable { handover.setPhoto(photo); await handover.uploadPhoto(); XCTAssertFalse(handover.ready) handover.borrowerChecked = true; XCTAssertTrue(handover.ready) await handover.confirm(); XCTAssertNotNil(handover.receipt); XCTAssertTrue(parent.requests.isEmpty) + XCTAssertEqual(handover.feedback?.kind, .success) + let feedback = handover.feedback + await parent.loadQueue(); XCTAssertEqual(handover.feedback, feedback, "A refresh must not replay a confirmation") } func testKitCountsStartEmptyAndRequireAnExplicitCountIncludingZero() async throws { let parent = state(kits: true); let handover = try await issue(parent) @@ -255,8 +258,11 @@ private final class StaffObservationChanges: @unchecked Sendable { let parent = state(); await parent.loadEvents(); let event = try XCTUnwrap(parent.events.first) let checkin = StaffCheckInState(parent: parent, event: event); await checkin.resolve("DEMO-EVENT") XCTAssertTrue(checkin.canConfirm); XCTAssertEqual(checkin.person?.status, "registered"); XCTAssertNil(checkin.confirmation) + XCTAssertEqual(checkin.feedback?.kind, .selection) await checkin.resolve("DEMO-EVENT"); XCTAssertEqual(checkin.person?.status, "registered") await checkin.confirm(); XCTAssertNotNil(checkin.confirmation); XCTAssertNil(checkin.person) + XCTAssertEqual(checkin.feedback?.kind, .success) + let feedback = checkin.feedback; await checkin.confirm(); XCTAssertEqual(checkin.feedback, feedback) await checkin.resolve("DEMO-EVENT"); XCTAssertEqual(checkin.person?.status, "attended"); XCTAssertFalse(checkin.canConfirm) } func testWaitlistedRegistrationCannotBeConfirmed() async throws { @@ -277,6 +283,7 @@ private final class StaffObservationChanges: @unchecked Sendable { parent.invalidate(); XCTAssertNil(handover.photo); XCTAssertNil(handover.evidenceID); XCTAssertTrue(handover.scans.isEmpty) XCTAssertNil(checkin.person); XCTAssertTrue(parent.requests.isEmpty); XCTAssertTrue(parent.events.isEmpty) await handover.confirm(); XCTAssertNil(handover.receipt); await checkin.confirm(); XCTAssertNil(checkin.confirmation) + XCTAssertNil(handover.feedback); XCTAssertNil(checkin.feedback) } func testBookingDecisionUsesReturnedServerState() async throws { let parent = state(); await parent.loadBookings(spaceID: 901); let row = try XCTUnwrap(parent.bookings.first) diff --git a/SpaceWorksUITests/MainFlowTests.swift b/SpaceWorksUITests/MainFlowTests.swift index c858a80..9a3d231 100644 --- a/SpaceWorksUITests/MainFlowTests.swift +++ b/SpaceWorksUITests/MainFlowTests.swift @@ -78,71 +78,84 @@ final class MainFlowTests: XCTestCase { app.buttons["appearance"].tap(); connect(app) let scroll = app.scrollViews["sign-in-scroll"] XCTAssertTrue(scroll.waitForExistence(timeout: 5)) - func revealActions() { - let signIn = app.buttons["sign-in"] - let create = app.buttons["create-account"] - XCTAssertTrue(signIn.waitForExistence(timeout: 5)) - XCTAssertTrue(create.waitForExistence(timeout: 5)) - for attempt in 0..<24 { - let applicationFrame = app.frame - let scrollFrame = scroll.frame.intersection(applicationFrame) - let appearance = app.buttons["appearance"] - XCTAssertTrue(appearance.exists) - let headerFrame = appearance.frame - let signInFrame = signIn.frame - let createFrame = create.frame - let frames = [applicationFrame, scrollFrame, headerFrame, signInFrame, createFrame] - XCTAssertTrue(frames.allSatisfy { [$0.minX, $0.minY, $0.width, $0.height].allSatisfy(\.isFinite) && $0.width > 0 && $0.height > 0 }) - XCTAssertTrue(appearance.isHittable) - let visibleTop = max(scrollFrame.minY, headerFrame.maxY) - let viewport = CGRect(x: scrollFrame.minX + 2, y: visibleTop + 2, - width: scrollFrame.width - 4, height: scrollFrame.maxY - visibleTop - 4) - XCTAssertGreaterThan(viewport.width, 0) - XCTAssertGreaterThan(viewport.height, 0) - print("Sign-in actions attempt=\(attempt) signIn=\(signInFrame) create=\(createFrame) viewport=\(viewport)") - if viewport.contains(signInFrame), viewport.contains(createFrame) { - XCTAssertTrue(signIn.isEnabled && signIn.isHittable) - XCTAssertTrue(create.isEnabled && create.isHittable) - XCTAssertEqual(app.state, .runningForeground) - return - } - XCTAssertTrue(scroll.isHittable) - let actionsFrame = signInFrame.union(createFrame) - // Center the two controls within real slack instead of repeatedly aligning a clipped edge. - let translation = viewport.midY - actionsFrame.midY - let adjustmentLimit = min(120, viewport.height * 0.4) - if abs(translation) <= adjustmentLimit { - let movement = (translation < 0 ? -1.0 : 1.0) * min(max(abs(translation), 12), adjustmentLimit) - let x = viewport.minX + 12 - let startY = viewport.midY - movement / 2 - let endY = viewport.midY + movement / 2 - let dragFrame = CGRect(x: x - 2, y: min(startY, endY) - 2, width: 4, height: abs(movement) + 4) - XCTAssertTrue(viewport.contains(dragFrame) && scrollFrame.contains(dragFrame)) - let controls = app.buttons.allElementsBoundByIndex + app.textFields.allElementsBoundByIndex - + app.secureTextFields.allElementsBoundByIndex + app.textViews.allElementsBoundByIndex - for control in controls where control.exists { - let frame = control.frame - if [frame.minX, frame.minY, frame.width, frame.height].allSatisfy(\.isFinite), frame.width > 0, frame.height > 0 { - XCTAssertFalse(frame.intersects(dragFrame), "The reveal gutter must not intersect an interactive control") - } - } - print("Sign-in short reveal path=\(dragFrame) visibleViewport=\(viewport)") - let origin = app.coordinate(withNormalizedOffset: .zero) - origin.withOffset(CGVector(dx: x - applicationFrame.minX, dy: startY - applicationFrame.minY)).press(forDuration: 0.05, - thenDragTo: origin.withOffset(CGVector(dx: x - applicationFrame.minX, dy: endY - applicationFrame.minY)), - withVelocity: .slow, thenHoldForDuration: 0.3) - } else if translation > 0 { scroll.swipeDown(velocity: .slow) } - else { scroll.swipeUp(velocity: .slow) } - } - XCTFail("Sign-in and Create account actions must both fit the visible sign-in scroll viewport") - } - revealActions(); attach(app, "Sign in largest text dark portrait") + revealSignInActions(app); attach(app, "Sign in largest text dark portrait") app.rotateToLandscapeAndWait() XCTAssertGreaterThan(app.frame.width, app.frame.height) scroll.swipeDown(velocity: .slow); XCTAssertTrue(app.textFields["username"].exists) - revealActions(); XCTAssertTrue(app.buttons["sign-in"].exists) + revealSignInActions(app); XCTAssertTrue(app.buttons["sign-in"].exists) attach(app, "Sign in largest text dark landscape") } + private func revealSignInActions(_ app: XCUIApplication) { + let scroll = app.scrollViews["sign-in-scroll"] + let signIn = app.buttons["sign-in"] + let create = app.buttons["create-account"] + XCTAssertTrue(signIn.waitForExistence(timeout: 5)) + XCTAssertTrue(create.waitForExistence(timeout: 5)) + for attempt in 0..<24 { + let applicationFrame = app.frame + let scrollFrame = scroll.frame.intersection(applicationFrame) + let appearance = app.buttons["appearance"] + XCTAssertTrue(appearance.exists) + let headerFrame = appearance.frame + let signInFrame = signIn.frame + let createFrame = create.frame + let frames = [applicationFrame, scrollFrame, headerFrame, signInFrame, createFrame] + XCTAssertTrue(frames.allSatisfy { [$0.minX, $0.minY, $0.width, $0.height].allSatisfy(\.isFinite) && $0.width > 0 && $0.height > 0 }) + XCTAssertTrue(appearance.isHittable) + let visibleTop = max(scrollFrame.minY, headerFrame.maxY) + let viewport = CGRect(x: scrollFrame.minX + 2, y: visibleTop + 2, + width: scrollFrame.width - 4, height: scrollFrame.maxY - visibleTop - 4) + XCTAssertGreaterThan(viewport.width, 0) + XCTAssertGreaterThan(viewport.height, 0) + print("Sign-in actions attempt=\(attempt) signIn=\(signInFrame) create=\(createFrame) viewport=\(viewport)") + if viewport.contains(signInFrame), viewport.contains(createFrame) { + XCTAssertTrue(signIn.isEnabled && signIn.isHittable) + XCTAssertTrue(create.isEnabled && create.isHittable) + XCTAssertEqual(app.state, .runningForeground) + return + } + XCTAssertTrue(scroll.isHittable) + let actionsFrame = signInFrame.union(createFrame) + // Center the two controls within real slack instead of repeatedly aligning a clipped edge. + let translation = viewport.midY - actionsFrame.midY + let adjustmentLimit = min(120, viewport.height * 0.4) + if abs(translation) <= adjustmentLimit { + let movement = (translation < 0 ? -1.0 : 1.0) * min(max(abs(translation), 12), adjustmentLimit) + let x = viewport.minX + 12 + let startY = viewport.midY - movement / 2 + let endY = viewport.midY + movement / 2 + let dragFrame = CGRect(x: x - 2, y: min(startY, endY) - 2, width: 4, height: abs(movement) + 4) + XCTAssertTrue(viewport.contains(dragFrame) && scrollFrame.contains(dragFrame)) + let controls = app.buttons.allElementsBoundByIndex + app.textFields.allElementsBoundByIndex + + app.secureTextFields.allElementsBoundByIndex + app.textViews.allElementsBoundByIndex + for control in controls where control.exists { + let frame = control.frame + if [frame.minX, frame.minY, frame.width, frame.height].allSatisfy(\.isFinite), frame.width > 0, frame.height > 0 { + XCTAssertFalse(frame.intersects(dragFrame), "The reveal gutter must not intersect an interactive control") + } + } + print("Sign-in short reveal path=\(dragFrame) visibleViewport=\(viewport)") + let origin = app.coordinate(withNormalizedOffset: .zero) + origin.withOffset(CGVector(dx: x - applicationFrame.minX, dy: startY - applicationFrame.minY)).press(forDuration: 0.05, + thenDragTo: origin.withOffset(CGVector(dx: x - applicationFrame.minX, dy: endY - applicationFrame.minY)), + withVelocity: .slow, thenHoldForDuration: 0.3) + } else if translation > 0 { scroll.swipeDown(velocity: .slow) } + else { scroll.swipeUp(velocity: .slow) } + } + XCTFail("Sign-in and Create account actions must both fit the visible sign-in scroll viewport") + } + func testRegularTextDarkLandscapeSignInUsesNativeBrandPanel() { + let app = launch(extra: ["--ui-start-light", "--ui-start-portrait", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"]) + app.buttons["appearance"].tap(); connect(app) + app.rotateToLandscapeAndWait() + XCTAssertGreaterThan(app.frame.width, app.frame.height) + XCTAssertEqual(app.buttons["appearance"].label, "Light mode") + XCTAssertTrue(app.textFields["username"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.secureTextFields["password"].exists) + revealSignInActions(app) + XCTAssertEqual(app.state, .runningForeground) + attach(app, "Sign in regular text dark landscape") + } func testExploreDemoIsIsolatedAndExitsToServerSetup() { let app = launch(); app.buttons["explore-demo"].tap() XCTAssertTrue(app.staticTexts["Demo mode · sample data"].exists) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 21c259d..c45cf04 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,15 @@ # Phase notes +## B4 Native interaction feedback and bounded onboarding motion — implementation checkpoint (2026-10-18) + +Explicit onboarding Back/Next actions now request native selection feedback; completion/Skip requests completion feedback. Only its existing page content uses the finite 200ms motion token, with a small eight-point fade/offset transition. The logo stays static. Real Reduce Motion (and the clearly scoped Debug test preference) removes that custom transition. Navigation stacks, tabs, sheets, native bars and system activities keep their native animation and stable ownership; no navigation-host identity or repeating effects are added. + +Feedback follows explicit owned operation outcomes: sign-in/MFA challenges and required-security steps are progression; success requires an accepted current credential installation and identity. Restoring/refreshing identity is quiet. Fresh request receipts, recorded handovers and confirmed attendance can request success; QR resolution is selection, not attendance. Current form validation/refusals request error; recovery-code delivery remains generic progression. Payment SDK submission, polling, uploads, background restoration and ordinary navigation never announce success. Form feedback is delivered only while its owning view remains present, with no guaranteed haptic after a verification callback dismisses that view. The platform controls whether supported hardware actually provides feedback. + +Each event is tied to the native activation epoch, retired synchronously on willResignActive and replaced on didBecomeActive. A callback deferred until foreground cannot replay an earlier/background event; newly mounted views do not emit an initial event. Existing operation generations/credential checks suppress retired results, and form dismissal retires its feedback revision. Three authored native cases cover native notification lifecycle/deferred delivery, an in-flight closed form, cancellation and local validation. Existing authentication, email-owner, request and staff regressions now also assert current feedback, quiet refresh and invalidated/replacement suppression. These validate event ownership, not a physical device sensation. + +A separate regular-text dark landscape sign-in journey reuses the unchanged strict action visibility checks and retains a full-OS capture for the wide tablet logo ground; the original largest-text journey is unchanged. Intended final totals are 302 native/37 UI/42 Python. Local source validation is recorded separately; exact Apple compilation, all six native profiles, unsigned Release/font bundle audit and actual new-source captures remain mandatory before acceptance. Parity: platform-native feedback accompanies explicit progression/results, typography remains the supplied family, all auth/member/staff actions and backend authority stay unchanged. + ## B3 Brand conformance and supported checkout typography — implementation checkpoint (2026-10-18) The owner rule in the pinned brand specification requires IDs and secrets to use the UI family with tabular digits. Five remaining rows now use the actual Gilroy Medium face with digit-only tabular spacing; values, selection and privacy handling are unchanged. This corrects B1's retained monospace exception. The wide auth panel now uses the paired semantic surface/ink ground, so its adaptive supplied full-color/reversed master remains legible in both appearances; the former fixed citron ground selected a reversed cream logo in dark mode. No logo is recolored, redrawn or distorted. From 7236e6ae0a28d6d89f233a4bcb65b9c0e93c1dc2 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:44:07 +0530 Subject: [PATCH 66/98] fix(b5): reveal native controls and preserve profile replacement Fully reveal phone fields and permission switches before native activation, verify input readiness and use native Select All for the rotation draft. Give notification headings their full accessibility width and align active delivery policy with the owner's consolidated dev workflow. Co-Authored-By: Codex --- AGENTS.md | 15 +++++++++------ README.md | 2 +- SpaceWorks/DemoNotificationsView.swift | 13 ++++++++++++- SpaceWorks/NotificationSettingsView.swift | 13 ++++++++++++- SpaceWorksUITests/AccountFlowTests.swift | 14 ++++++++++++-- SpaceWorksUITests/MemberActionsFlowTests.swift | 12 ++++++++++-- SpaceWorksUITests/NotificationsFlowTests.swift | 8 ++++++-- docs/CHANGELOG.md | 10 ++++++++++ docs/IMPLEMENTATION-PLAN.md | 4 +++- docs/RELEASE-READINESS.md | 10 ++++++---- docs/STORE-LISTING.md | 3 ++- 11 files changed, 83 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 26ede60..004a647 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,18 +19,21 @@ ## Branch delivery workflow -The owner merged the A0 foundation and requested this workflow on 2026-10-16: +The owner merged the A0 foundation and subsequently updated the delivery workflow: - Start ongoing work from `dev`, commit there, and push to `origin/dev`. -- Finish the phase's implementation and validation before opening its final `dev` → `main` pull request. -- Keep `main` for reviewed merges. Preserve the owner's configured commit identity and the required Codex co-author trailer. +- Do not open pull requests or create feature branches for this batch; consolidate on `dev`. After the required repository native/CI, unsigned Release and actual visual gates pass, merge the tested `dev` directly into `main`, as authorized by the owner. +- Retire obsolete work branches only after their work is safely retained. Preserve the owner's configured commit identity and the required Codex co-author trailer. -## Complete-build execution addendum (owner, 2026-10-16) +## Complete-build execution addendum (owner, 2026-10-16; delivery updated 2026-10-18) The owner requested the remaining phases as one batch, with Android and iOS agents in parallel. Implement each phase sequentially on `dev`, with phase commits, tests, CI evidence, and a changelog -parity checklist. A green phase may proceed without an intermediate merge; the existing final -`dev` → `main` PR tracks the batch. The owner performs merges. Preserve `docs/PRD.md` byte-for-byte. +parity checklist. A green phase may proceed without an intermediate merge. Consolidate the batch +on `dev`, complete the required repository validation and actual visual review, then merge the tested +work directly into `main` under the owner's latest authorization. Follow the branch delivery policy +above; do not open pull requests or create feature branches for this batch. Preserve `docs/PRD.md` +byte-for-byte. Phone and tablet layouts, portrait/landscape, light/dark and largest Dynamic Type are now in scope. Use generated contract operations only. Record missing backend contracts as `backend-request` issues and continue independent work when prerequisites permit. Local Docker is the development API; diff --git a/README.md b/README.md index 7831bd6..a736794 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ iOS 17+ native SwiftUI app for iPhone and iPad. [The PRD](docs/PRD.md) defines t ## Development workflow -Use `dev` for ongoing changes and push commits to `origin/dev`. After implementation and CI validation finish, open the final pull request from `dev` to `main`. The A0 foundation is already merged into `main`. +Use `dev` for ongoing changes and push commits to `origin/dev`. After implementation, required native/CI tests, unsigned Release validation and actual visual review pass, merge the tested `dev` directly into `main`, as authorized by the owner. Do not open pull requests or create feature branches for this batch; consolidate on `dev`. The A0 foundation is already merged into `main`. ## Build and test diff --git a/SpaceWorks/DemoNotificationsView.swift b/SpaceWorks/DemoNotificationsView.swift index fadd475..1acfc8c 100644 --- a/SpaceWorks/DemoNotificationsView.swift +++ b/SpaceWorks/DemoNotificationsView.swift @@ -10,6 +10,7 @@ import Observation } struct DemoNotificationsView: View { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Bindable var state: DemoNotificationState let makerspaceID: Int let route: (MobileLink) -> Void @@ -17,7 +18,17 @@ struct DemoNotificationsView: View { ScrollView { VStack(alignment: .leading, spacing: 24) { SWCard { - Label("Notifications", systemImage: "bell.badge").brandFont(.title2, weight: .bold) + Group { + if dynamicTypeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 12) { + Image(systemName: "bell.badge").accessibilityHidden(true) + Text("Notifications").frame(maxWidth: .infinity, alignment: .leading) + } + } else { + Label("Notifications", systemImage: "bell.badge") + } + }.brandFont(.title2, weight: .bold) + .accessibilityElement(children: .combine).accessibilityAddTraits(.isHeader) Text("Demo only. Changes stay in this preview. No notifications will be sent.").foregroundStyle(Color.swMuted) Toggle("Preview permission denied", isOn: $state.permissionDenied).frame(minHeight: 48) if state.permissionDenied { diff --git a/SpaceWorks/NotificationSettingsView.swift b/SpaceWorks/NotificationSettingsView.swift index 859330d..d1ea389 100644 --- a/SpaceWorks/NotificationSettingsView.swift +++ b/SpaceWorks/NotificationSettingsView.swift @@ -3,12 +3,23 @@ import UserNotifications /// OS delivery permission and account-level server registration are separate controls. struct NotificationSettingsView: View { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize let state: NotificationSettingsState var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { SWCard { - Label("Notifications", systemImage: "bell.badge").brandFont(.title2, weight: .bold) + Group { + if dynamicTypeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 12) { + Image(systemName: "bell.badge").accessibilityHidden(true) + Text("Notifications").frame(maxWidth: .infinity, alignment: .leading) + } + } else { + Label("Notifications", systemImage: "bell.badge") + } + }.brandFont(.title2, weight: .bold) + .accessibilityElement(children: .combine).accessibilityAddTraits(.isHeader) Text("Keep up with requests, bookings, and important makerspace updates. Your inbox is always available.").foregroundStyle(Color.swMuted) if !state.registration.configured { Text("Notifications are not configured for this build. Your inbox is still available.").foregroundStyle(Color.swMuted) diff --git a/SpaceWorksUITests/AccountFlowTests.swift b/SpaceWorksUITests/AccountFlowTests.swift index 4ad0bdb..c56b6c9 100644 --- a/SpaceWorksUITests/AccountFlowTests.swift +++ b/SpaceWorksUITests/AccountFlowTests.swift @@ -132,9 +132,19 @@ final class AccountFlowTests: XCTestCase { } func testPhoneVerificationThenCurrentSessionRevocationRequiresSignIn() { let app = account(); tap(app.buttons["account-phone"], in: app) - let phone = app.textFields["account-phone-number"]; tap(phone, in: app); phone.typeText("9876543210") + let phone = app.textFields["account-phone-number"] + NativeUIActions.tap(phone, in: app, requireWholeFrame: true) + XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5)) + XCTAssertTrue(app.navigationBars.buttons["account-phone-keyboard-done"].waitForExistence(timeout: 5)) + phone.typeText("9876543210") + XCTAssertEqual(phone.value as? String, "9876543210") dismissAccountKeyboard("account-phone-keyboard-done", in: app); tap(app.buttons["account-phone-send"], in: app) - let code = app.textFields["account-phone-code"]; tap(code, in: app); code.typeText("123456") + let code = app.textFields["account-phone-code"] + NativeUIActions.tap(code, in: app, requireWholeFrame: true) + XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5)) + XCTAssertTrue(app.navigationBars.buttons["account-phone-keyboard-done"].waitForExistence(timeout: 5)) + code.typeText("123456") + XCTAssertEqual(code.value as? String, "123456") dismissAccountKeyboard("account-phone-keyboard-done", in: app); tap(app.buttons["account-phone-verify"], in: app) XCTAssertTrue(app.staticTexts["+919876543210"].waitForExistence(timeout: 10)); attach("Account verified phone sample") tap(app.navigationBars.buttons.element(boundBy: 0), in: app) diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index 93592d8..9ef5926 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -267,8 +267,16 @@ final class MemberActionsFlowTests: XCTestCase { } func testProfileDraftSurvivesRotationAndOptionalPreferencesCanChange() { let app = demo(); tab(app, "Profile"); tap(app.buttons["Edit profile"], in: app) - let name = app.textFields["profile-display-name"]; tap(name, in: app) - let old = name.value as? String ?? ""; name.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: old.count) + "Meera Maker\n") + let name = app.textFields["profile-display-name"] + NativeUIActions.tap(name, in: app, requireWholeFrame: true) + XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5)) + name.press(forDuration: 1) + let selectAll = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Select All")).firstMatch + XCTAssertTrue(selectAll.waitForExistence(timeout: 5), "The native text edit menu must offer Select All.") + XCTAssertTrue(selectAll.isHittable) + selectAll.tap() + name.typeText("Meera Maker\n") + XCTAssertEqual(name.value as? String, "Meera Maker", "Verify the replacement before testing rotation preservation.") app.rotateToLandscapeAndWait() XCTAssertGreaterThan(app.frame.width, app.frame.height); XCTAssertEqual(name.value as? String, "Meera Maker") tap(app.buttons["Save changes"], in: app); XCTAssertTrue(app.staticTexts["Profile saved."].waitForExistence(timeout: 5)); capture("Native profile saved landscape") diff --git a/SpaceWorksUITests/NotificationsFlowTests.swift b/SpaceWorksUITests/NotificationsFlowTests.swift index cea358f..d3ecb8d 100644 --- a/SpaceWorksUITests/NotificationsFlowTests.swift +++ b/SpaceWorksUITests/NotificationsFlowTests.swift @@ -32,11 +32,15 @@ final class NotificationsFlowTests: XCTestCase { let image = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); image.name = name; image.lifetime = .keepAlways; add(image) } func testPermissionDeniedAndUnauthorizedTapHaveRecoverableInAppStates() { - let app = notifications(); tap(app.switches["Preview permission denied"], app) + let app = notifications() + XCTAssertTrue(app.navigationBars["Notifications"].waitForExistence(timeout: 5)) + capture("Native notification heading") + NativeUIActions.tap(app.switches["Preview permission denied"], in: app, requireWholeFrame: true) XCTAssertTrue(app.staticTexts["Notifications are off for this device."].exists) tap(app.buttons["Open system settings"], app) XCTAssertTrue(app.staticTexts["notification-preview-feedback"].exists); XCTAssertEqual(app.state, .runningForeground) - tap(app.switches["Preview permission denied"], app); tap(app.buttons["enable-demo-notifications"], app) + NativeUIActions.tap(app.switches["Preview permission denied"], in: app, requireWholeFrame: true) + tap(app.buttons["enable-demo-notifications"], app) tap(app.buttons["demo-push-denied"], app) XCTAssertTrue(app.staticTexts["mobile-link-error"].waitForExistence(timeout: 5)) XCTAssertEqual(app.staticTexts["mobile-link-error"].label, "This makerspace isn't available to your account.") diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c45cf04..6ffb8d2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,15 @@ # Phase notes +## B5 Complete native control activation and accessibility headings — correction checkpoint (2026-10-18) + +The exact `34bb979` large-phone run passed all 302 native cases but failed three of 37 UI journeys. Its phone input and permission switch were only partly exposed above the native tab bar: the helper activated an exposed edge without establishing keyboard focus or changing the switch. The two phone fields and both permission-switch actions now use the existing full-frame reveal, preserving the 24-gesture bound and actual native activation. The phone journey checks the actual keyboard, focus-bound Done and each entered value before its unchanged verification and current-session revocation outcomes. No app focus state or shared gesture behavior changes. + +The profile recording showed the insertion cursor before the existing surname. Repeated backspaces removed only its prefix, leaving the surname appended before rotation. The journey now fully reveals the field, uses the native Select All edit action, and asserts the exact replacement before the unchanged rotation, save and preference checks. It fails if native selection is unavailable; no clipboard or application-state shortcut is used. + +Actual largest-text notification media also showed the decorative bell squeezing the card heading into a split word. Both matching live/demo headings place that decorative symbol above full-width semantic title text at accessibility sizes, retaining the supplied font, scaling, colors and ordinary-size Label. The existing permission journey captures the actual heading for new-source review. All operations, case counts, deadlines and downstream assertions remain. The five active delivery-policy documents now follow the owner's consolidated dev-only/no-PR workflow and authorized tested merge to main; dated implementation history is preserved. + +The `34bb979` canonical push separately passed 302 native/37 UI and unsigned device Release, including actual scene=false and bundled-font/resource audits. That proof is historical for that source, not acceptance of this successor. Intended counts remain 302 native/37 UI/42 Python; complete corrected exact-source canonical, six-profile native/Release and actual visual gates remain required before delivery. + ## B4 Native interaction feedback and bounded onboarding motion — implementation checkpoint (2026-10-18) Explicit onboarding Back/Next actions now request native selection feedback; completion/Skip requests completion feedback. Only its existing page content uses the finite 200ms motion token, with a small eight-point fade/offset transition. The logo stays static. Real Reduce Motion (and the clearly scoped Debug test preference) removes that custom transition. Navigation stacks, tabs, sheets, native bars and system activities keep their native animation and stable ownership; no navigation-host identity or repeating effects are added. diff --git a/docs/IMPLEMENTATION-PLAN.md b/docs/IMPLEMENTATION-PLAN.md index ab5605f..fb31885 100644 --- a/docs/IMPLEMENTATION-PLAN.md +++ b/docs/IMPLEMENTATION-PLAN.md @@ -3,7 +3,9 @@ The owner extended the PRD scope on 2026-10-16 to include tablets and requested the remaining phases in one batch on `dev`. The original PRD remains unchanged. Each phase has generated-client API coverage, state tests, main-flow UI tests, green macOS CI, commits and a parity note before the -next independent phase. The owner merges the final `dev` → `main` PR. +next independent phase. Under the owner's latest delivery instruction, consolidate work on `dev` +and merge the tested branch directly into `main` after required repository validation and actual +visual review pass. No final pull request is required. | Phase | Deliverable | Gate | |---|---|---| diff --git a/docs/RELEASE-READINESS.md b/docs/RELEASE-READINESS.md index e4554d6..46aa6d6 100644 --- a/docs/RELEASE-READINESS.md +++ b/docs/RELEASE-READINESS.md @@ -3,8 +3,10 @@ Prepared 16 October 2026. This is the release procedure and evidence ledger, not a claim of store submission. Phase implementation and passing checks are recorded in `docs/CHANGELOG.md`. The owner requested phones and tablets and deferred backend setup; that instruction supersedes -the original PRD's tablet exclusion and intermediate merge requirement. Work stays on `dev` -until the owner reviews the final `dev` to `main` pull request. +the original PRD's tablet exclusion and intermediate merge requirement. Under the owner's latest +delivery instruction, work stays on `dev` until required repository native/CI tests, unsigned Release +validation and actual visual review pass, then the tested branch merges directly into `main`. No +final pull request is required; deployment and store-provisioning gates below remain separate. ## Reproduce the app @@ -139,8 +141,8 @@ Record distribution evidence here after owner provisioning; no credentials belon | Evidence | Value | |---|---| -| Accepted app / Design commits | The exact tested source SHA and pinned Design input belong in the final [dev to main PR](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/pull/2) and matching CI artifacts; acceptance requires its completed validation section | -| Full device-matrix CI run | The final PR validation section must link the accepted six-profile run and reviewed artifacts; a pending or failed run does not establish acceptance | +| Accepted app / Design commits | Record the exact tested application SHA, pinned Design input and matching accepted CI artifacts in the final delivery ledger. If a later documentation-only commit follows validation, disclose it separately and verify that every application, resource, test and build input is unchanged; do not relabel the native evidence. | +| Full device-matrix CI run | The final delivery ledger must link the accepted exact-source six-profile run, canonical `dev` push validation and actually reviewed artifacts; a pending or failed run does not establish acceptance. Merge to `main` only after the required repository gates pass. | | Signed archive / version / build | Owner signing environment required | | Real-device provider test record | Owner backend/configuration required | | Whole-account deletion verification | Updated owner contract required | diff --git a/docs/STORE-LISTING.md b/docs/STORE-LISTING.md index 342a39d..37a650e 100644 --- a/docs/STORE-LISTING.md +++ b/docs/STORE-LISTING.md @@ -68,7 +68,8 @@ final submitted build against the owner's supported backend with a synthetic rev Retain original dimensions and current console device slots; never stretch a phone image into a tablet image. Keep recovery codes, verification codes and QR payloads out of store assets; do not weaken secure-window protections to capture them. Optional staff images must show a -verified authorized staff deployment. The final PR links the accepted layout-proof artifacts. +verified authorized staff deployment. The final repository delivery ledger links the accepted +layout-proof artifacts and exact tested application SHA. ## Reviewer access and review notes From 631518f2b72fae60b151fb445516bd0539229fd4 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:51:21 +0530 Subject: [PATCH 67/98] test(b5): prove notification heading at largest text Keep normal profile notification-refetch coverage while the existing permission and authorization-refusal journey explicitly requests AX5 on every profile. Preserve every action, outcome and the same case count. Co-Authored-By: Codex --- SpaceWorksUITests/NotificationsFlowTests.swift | 8 ++++---- docs/CHANGELOG.md | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/SpaceWorksUITests/NotificationsFlowTests.swift b/SpaceWorksUITests/NotificationsFlowTests.swift index d3ecb8d..78603ad 100644 --- a/SpaceWorksUITests/NotificationsFlowTests.swift +++ b/SpaceWorksUITests/NotificationsFlowTests.swift @@ -18,8 +18,8 @@ final class NotificationsFlowTests: XCTestCase { } tap(item, app) } - private func notifications() -> XCUIApplication { - let app = XCUIApplication(); app.launchArguments = ["--ui-testing"]; app.launchForDeviceProfile() + private func notifications(extra: [String] = []) -> XCUIApplication { + let app = XCUIApplication(); app.launchArguments = ["--ui-testing"] + extra; app.launchForDeviceProfile() tap(app.buttons["explore-demo"], app); tap(app.buttons["Continue as member"], app) let username = app.textFields["demo-username"]; tap(username, app); username.typeText("meera\n") app.secureTextFields["demo-password"].typeText("sample\n") @@ -31,8 +31,8 @@ final class NotificationsFlowTests: XCTestCase { private func capture(_ name: String) { let image = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); image.name = name; image.lifetime = .keepAlways; add(image) } - func testPermissionDeniedAndUnauthorizedTapHaveRecoverableInAppStates() { - let app = notifications() + func testLargestTextPermissionDeniedAndUnauthorizedTapHaveRecoverableInAppStates() { + let app = notifications(extra: ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]) XCTAssertTrue(app.navigationBars["Notifications"].waitForExistence(timeout: 5)) capture("Native notification heading") NativeUIActions.tap(app.switches["Preview permission denied"], in: app, requireWholeFrame: true) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6ffb8d2..a1f1cf7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## B5 Complete native control activation and accessibility headings — correction checkpoint (2026-10-18) +The existing permission/authorization-refusal journey explicitly requests the largest accessibility text category on every device, including compact phones, and its method name records that comparison launch. The other notification-refetch journey retains ordinary profile text coverage. This strengthens the same heading capture and outcomes without adding a case; the superseded `7236e6a` runs provide no final acceptance. + The exact `34bb979` large-phone run passed all 302 native cases but failed three of 37 UI journeys. Its phone input and permission switch were only partly exposed above the native tab bar: the helper activated an exposed edge without establishing keyboard focus or changing the switch. The two phone fields and both permission-switch actions now use the existing full-frame reveal, preserving the 24-gesture bound and actual native activation. The phone journey checks the actual keyboard, focus-bound Done and each entered value before its unchanged verification and current-session revocation outcomes. No app focus state or shared gesture behavior changes. The profile recording showed the insertion cursor before the existing surname. Repeated backspaces removed only its prefix, leaving the surname appended before rotation. The journey now fully reveals the field, uses the native Select All edit action, and asserts the exact replacement before the unchanged rotation, save and preference checks. It fails if native selection is unavailable; no clipboard or application-state shortcut is used. From 7ff1398ea06f74a154c8459feaf2ebb47208e048 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 06:01:51 +0530 Subject: [PATCH 68/98] test(b5): verify demo destination before member setup Fully reveal and natively activate Continue before requiring the real sign-in destination. Apply the same bounded readiness to the analogous demo-isolation setup while preserving every journey outcome. Co-Authored-By: Codex --- SpaceWorksUITests/MainFlowTests.swift | 3 ++- SpaceWorksUITests/MemberFlowTests.swift | 4 +++- docs/CHANGELOG.md | 2 ++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/SpaceWorksUITests/MainFlowTests.swift b/SpaceWorksUITests/MainFlowTests.swift index 9a3d231..71ae409 100644 --- a/SpaceWorksUITests/MainFlowTests.swift +++ b/SpaceWorksUITests/MainFlowTests.swift @@ -159,7 +159,8 @@ final class MainFlowTests: XCTestCase { func testExploreDemoIsIsolatedAndExitsToServerSetup() { let app = launch(); app.buttons["explore-demo"].tap() XCTAssertTrue(app.staticTexts["Demo mode · sample data"].exists) - app.buttons["Continue as member"].tap() + NativeUIActions.tap(app.buttons["Continue as member"], in: app, requireWholeFrame: true) + XCTAssertTrue(app.textFields["demo-username"].waitForExistence(timeout: 5), "The actual demo sign-in destination must open before scrolling and typing.") app.swipeUp() app.textFields["demo-username"].tap(); app.textFields["demo-username"].typeText("meera\n") app.secureTextFields["demo-password"].typeText("sample\n") diff --git a/SpaceWorksUITests/MemberFlowTests.swift b/SpaceWorksUITests/MemberFlowTests.swift index 9b392c7..2ddaa8a 100644 --- a/SpaceWorksUITests/MemberFlowTests.swift +++ b/SpaceWorksUITests/MemberFlowTests.swift @@ -6,7 +6,9 @@ final class MemberFlowTests: XCTestCase { let app = XCUIApplication() app.launchArguments = ["--ui-testing"] + (argument.map { [$0] } ?? []) app.launchForDeviceProfile() - app.buttons["explore-demo"].tap(); app.buttons["Continue as member"].tap() + app.buttons["explore-demo"].tap() + NativeUIActions.tap(app.buttons["Continue as member"], in: app, requireWholeFrame: true) + XCTAssertTrue(app.textFields["demo-username"].waitForExistence(timeout: 5), "The actual demo sign-in destination must open before scrolling and typing.") app.swipeUp(); app.textFields["demo-username"].tap(); app.textFields["demo-username"].typeText("meera\n") app.secureTextFields["demo-password"].typeText("sample\n") app.swipeUp(); app.buttons["demo-sign-in"].tap(); app.buttons["demo-space-0"].tap() diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a1f1cf7..1a80d39 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## B5 Complete native control activation and accessibility headings — correction checkpoint (2026-10-18) +The original `34bb979` compact run passed 302 native cases and failed one of 37 UI journeys during demo setup, before inventory opened. Its native recording and final hierarchy remained on the role chooser after an auto-scrolling Continue tap and immediate whole-app swipe; no username field or keyboard had appeared. The member-flow helper now fully reveals and natively activates that existing Continue button with the unchanged bounded helper, then requires the actual sign-in field before its original scroll/type steps. The directly analogous Main demo-isolation setup receives the same readiness checks as a source-consistency precaution; that Main case did not fail in this run. No product callback cause is established, and no tap retry, application hook, case removal or timeout extension is introduced. + The existing permission/authorization-refusal journey explicitly requests the largest accessibility text category on every device, including compact phones, and its method name records that comparison launch. The other notification-refetch journey retains ordinary profile text coverage. This strengthens the same heading capture and outcomes without adding a case; the superseded `7236e6a` runs provide no final acceptance. The exact `34bb979` large-phone run passed all 302 native cases but failed three of 37 UI journeys. Its phone input and permission switch were only partly exposed above the native tab bar: the helper activated an exposed edge without establishing keyboard focus or changing the switch. The two phone fields and both permission-switch actions now use the existing full-frame reveal, preserving the 24-gesture bound and actual native activation. The phone journey checks the actual keyboard, focus-bound Done and each entered value before its unchanged verification and current-session revocation outcomes. No app focus state or shared gesture behavior changes. From 68013537bb4370fd9b25acfb3794f34e24e8d034 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:08:29 +0530 Subject: [PATCH 69/98] fix(b5): fit notification heading and use native paragraph selection Co-Authored-By: Codex --- SpaceWorks/AuthView.swift | 9 +++++++-- SpaceWorks/DemoNotificationsView.swift | 4 ++-- SpaceWorks/NotificationSettingsView.swift | 4 ++-- SpaceWorksUITests/MemberActionsFlowTests.swift | 8 +++----- docs/CHANGELOG.md | 4 ++++ 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/SpaceWorks/AuthView.swift b/SpaceWorks/AuthView.swift index d803f26..495024f 100644 --- a/SpaceWorks/AuthView.swift +++ b/SpaceWorks/AuthView.swift @@ -80,10 +80,15 @@ struct PasswordChangeView: View { } struct SWCard: View { - @ViewBuilder var content: Content + let contentPadding: CGFloat + let content: Content + init(contentPadding: CGFloat = 24, @ViewBuilder content: () -> Content) { + self.contentPadding = contentPadding + self.content = content() + } var body: some View { VStack(alignment: .leading, spacing: 16) { content } - .padding(24).frame(maxWidth: .infinity, alignment: .leading) + .padding(contentPadding).frame(maxWidth: .infinity, alignment: .leading) .background(Color.swPanel, in: RoundedRectangle(cornerRadius: 16)) .overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.swLine)) } diff --git a/SpaceWorks/DemoNotificationsView.swift b/SpaceWorks/DemoNotificationsView.swift index 1acfc8c..6e17ae0 100644 --- a/SpaceWorks/DemoNotificationsView.swift +++ b/SpaceWorks/DemoNotificationsView.swift @@ -17,7 +17,7 @@ struct DemoNotificationsView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - SWCard { + SWCard(contentPadding: dynamicTypeSize.isAccessibilitySize ? 12 : 24) { Group { if dynamicTypeSize.isAccessibilitySize { VStack(alignment: .leading, spacing: 12) { @@ -50,7 +50,7 @@ struct DemoNotificationsView: View { Button("Preview unauthorized makerspace") { route(.init(makerspaceID: 999, destination: .inbox)) }.frame(minHeight: 48).accessibilityIdentifier("demo-push-denied") Button("Preview device refresh") { state.rotations += 1; state.feedback = String(localized: "Demo notification settings refreshed. No notifications were sent.") }.frame(minHeight: 48) }.disabled(!state.enabled || state.permissionDenied) - }.padding(24).frame(maxWidth: 640).frame(maxWidth: .infinity) + }.padding(dynamicTypeSize.isAccessibilitySize ? 12 : 24).frame(maxWidth: 640).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle("Notifications") } } diff --git a/SpaceWorks/NotificationSettingsView.swift b/SpaceWorks/NotificationSettingsView.swift index d1ea389..0a18988 100644 --- a/SpaceWorks/NotificationSettingsView.swift +++ b/SpaceWorks/NotificationSettingsView.swift @@ -8,7 +8,7 @@ struct NotificationSettingsView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - SWCard { + SWCard(contentPadding: dynamicTypeSize.isAccessibilitySize ? 12 : 24) { Group { if dynamicTypeSize.isAccessibilitySize { VStack(alignment: .leading, spacing: 12) { @@ -46,7 +46,7 @@ struct NotificationSettingsView: View { if let error = state.error ?? state.registration.error { SWError(message: error) } } SWCard { Text("Choose which updates you receive in Communication preferences. Required safety messages remain available in your inbox.").foregroundStyle(Color.swMuted) } - }.padding(24).frame(maxWidth: 640).frame(maxWidth: .infinity) + }.padding(dynamicTypeSize.isAccessibilitySize ? 12 : 24).frame(maxWidth: 640).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle("Notifications") .task { await state.prepare() } } diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index 9ef5926..13ee559 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -270,11 +270,9 @@ final class MemberActionsFlowTests: XCTestCase { let name = app.textFields["profile-display-name"] NativeUIActions.tap(name, in: app, requireWholeFrame: true) XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5)) - name.press(forDuration: 1) - let selectAll = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Select All")).firstMatch - XCTAssertTrue(selectAll.waitForExistence(timeout: 5), "The native text edit menu must offer Select All.") - XCTAssertTrue(selectAll.isHittable) - selectAll.tap() + XCTAssertEqual(name.value as? String, "Meera Nair") + name.tap(withNumberOfTaps: 3, numberOfTouches: 1) + name.typeText(XCUIKeyboardKey.delete.rawValue) name.typeText("Meera Maker\n") XCTAssertEqual(name.value as? String, "Meera Maker", "Verify the replacement before testing rotation preservation.") app.rotateToLandscapeAndWait() diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1a80d39..cf142a2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,10 @@ ## B5 Complete native control activation and accessibility headings — correction checkpoint (2026-10-18) +The `7ff1398` canonical phone run passed 302 native cases and 36 of 37 UI journeys, but its profile test's long press never exposed a native Select All menu. Actual recording frames and the final hierarchy showed the original focused name and keyboard without any edit-menu node; replacement and rotation had not started. That test now verifies the original name, uses Apple's documented one-finger triple-tap paragraph selection and a native delete, then retains its exact replacement assertion before the unchanged rotation, save and preferences outcomes. Any retained prefix or suffix fails that assertion before rotation; no empty-field placeholder assumption, clipboard, caret-position assumption, application hook or retry is added. Separately, the two initial tablet failures occurred inside XCTest launch background-assertion acquisition, before account/deletion actions; no product cause or source remedy is claimed for those launcher failures. + +The same source's actual largest-text phone heading still broke Notifications before its final letter despite the vertical decorative bell. The live and demo notification pages now use 12-point outer insets and 12-point heading-card content padding only at accessibility sizes, recovering width for the full-scale title. `SWCard` keeps its ordinary 24-point default through an explicit builder initializer; every other card and normal-size notification inset is unchanged. Font, heading semantics, symbol, text, colors and callbacks remain. Fresh compact and phone pixels must prove the complete word fits; no font cap or truncation is introduced. Intended totals remain 302 native/37 UI/42 Python, and the failed `7ff1398` canonical run is diagnostic rather than final acceptance. + The original `34bb979` compact run passed 302 native cases and failed one of 37 UI journeys during demo setup, before inventory opened. Its native recording and final hierarchy remained on the role chooser after an auto-scrolling Continue tap and immediate whole-app swipe; no username field or keyboard had appeared. The member-flow helper now fully reveals and natively activates that existing Continue button with the unchanged bounded helper, then requires the actual sign-in field before its original scroll/type steps. The directly analogous Main demo-isolation setup receives the same readiness checks as a source-consistency precaution; that Main case did not fail in this run. No product callback cause is established, and no tap retry, application hook, case removal or timeout extension is introduced. The existing permission/authorization-refusal journey explicitly requests the largest accessibility text category on every device, including compact phones, and its method name records that comparison launch. The other notification-refetch journey retains ordinary profile text coverage. This strengthens the same heading capture and outcomes without adding a case; the superseded `7236e6a` runs provide no final acceptance. From 1d0e4a72c0ba97f52ec2b8701f051e0fe7e53f76 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 08:23:38 +0530 Subject: [PATCH 70/98] fix(b5): use native profile keyboard action and verify input readiness Co-Authored-By: Codex --- SpaceWorks/MemberSettingsViews.swift | 7 +++- SpaceWorksUITests/AccountFlowTests.swift | 9 ++++- .../MemberActionsFlowTests.swift | 33 ++++++++++++++++--- docs/CHANGELOG.md | 6 ++++ 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/SpaceWorks/MemberSettingsViews.swift b/SpaceWorks/MemberSettingsViews.swift index 8c080ef..0011322 100644 --- a/SpaceWorks/MemberSettingsViews.swift +++ b/SpaceWorks/MemberSettingsViews.swift @@ -37,7 +37,12 @@ struct ProfileEditorView: View { }.background(Color.swBg).navigationTitle("Profile details").scrollDismissesKeyboard(.interactively) .navigationBarBackButtonHidden(state.dirty || state.busy).interactiveDismissDisabled(state.dirty || state.busy) .toolbar { - ToolbarItemGroup(placement: .keyboard) { Spacer(); Button("Done") { focusedField = nil } } + if focusedField != nil { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { focusedField = nil } + .accessibilityLabel(Text("Done")).accessibilityIdentifier("profile-keyboard-done") + } + } if state.dirty || state.busy { ToolbarItem(placement: .topBarLeading) { Button("Back") { wantsExit = true; discardConfirmation = true }.frame(minHeight: 48).disabled(state.busy) } } } .task { if !state.dirty { await state.load() } } diff --git a/SpaceWorksUITests/AccountFlowTests.swift b/SpaceWorksUITests/AccountFlowTests.swift index c56b6c9..5a85ae3 100644 --- a/SpaceWorksUITests/AccountFlowTests.swift +++ b/SpaceWorksUITests/AccountFlowTests.swift @@ -10,6 +10,7 @@ final class AccountFlowTests: XCTestCase { app.secureTextFields["demo-password"].typeText("sample\n"); tap(app.buttons["demo-sign-in"], in: app) if !withoutMakerspace { tap(app.buttons["demo-space-0"], in: app) + XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: 10)) if app.tabBars.buttons["Profile"].exists { tap(app.tabBars.buttons["Profile"], in: app) } else { let profile = app.buttons["nav-Profile"] @@ -136,7 +137,13 @@ final class AccountFlowTests: XCTestCase { NativeUIActions.tap(phone, in: app, requireWholeFrame: true) XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5)) XCTAssertTrue(app.navigationBars.buttons["account-phone-keyboard-done"].waitForExistence(timeout: 5)) - phone.typeText("9876543210") + var enteredPhone = "" + for digit in "9876543210" { + enteredPhone.append(digit) + phone.typeText(String(digit)) + let accepted = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == %@", enteredPhone), object: phone) + XCTAssertEqual(XCTWaiter.wait(for: [accepted], timeout: 5), .completed, "Each intended native phone digit must be accepted before entering the next.") + } XCTAssertEqual(phone.value as? String, "9876543210") dismissAccountKeyboard("account-phone-keyboard-done", in: app); tap(app.buttons["account-phone-send"], in: app) let code = app.textFields["account-phone-code"] diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index 13ee559..6ccc2a7 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -206,8 +206,11 @@ final class MemberActionsFlowTests: XCTestCase { } private func demo(_ arguments: [String] = []) -> XCUIApplication { let app = XCUIApplication(); app.launchArguments = ["--ui-testing"] + arguments; app.launchForDeviceProfile() - tap(app.buttons["explore-demo"], in: app); tap(app.buttons["Continue as member"], in: app) - let username = app.textFields["demo-username"]; tap(username, in: app) + tap(app.buttons["explore-demo"], in: app) + let member = app.buttons["Continue as member"] + XCTAssertTrue(member.waitForExistence(timeout: 5)); tap(member, in: app) + let username = app.textFields["demo-username"] + XCTAssertTrue(username.waitForExistence(timeout: 5)); tap(username, in: app) XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5)); username.typeText("meera\n") app.secureTextFields["demo-password"].typeText("sample\n") tap(app.buttons["demo-sign-in"], in: app); tap(app.buttons["demo-space-0"], in: app) @@ -270,11 +273,31 @@ final class MemberActionsFlowTests: XCTestCase { let name = app.textFields["profile-display-name"] NativeUIActions.tap(name, in: app, requireWholeFrame: true) XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5)) - XCTAssertEqual(name.value as? String, "Meera Nair") - name.tap(withNumberOfTaps: 3, numberOfTouches: 1) + let done = app.navigationBars.buttons["profile-keyboard-done"] + XCTAssertTrue(done.waitForExistence(timeout: 5)) + let original = "Meera Nair" + XCTAssertEqual(name.value as? String, original) + let frame = name.frame + XCTAssertTrue(name.isHittable) + XCTAssertTrue(frame.minX.isFinite && frame.minY.isFinite && frame.width.isFinite && frame.height.isFinite) + XCTAssertGreaterThan(frame.width, 44); XCTAssertGreaterThan(frame.height, 0) + // The field has 14-point content padding; activate its trailing editing interior. + let insertionPoint = CGPoint(x: frame.maxX - 22, y: frame.midY) + let viewport = visibleContent(containingScroll(name, app), app, gestureInset: 0).insetBy(dx: 2, dy: 2) + XCTAssertTrue(viewport.contains(insertionPoint)) + app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: insertionPoint.x - app.frame.minX, dy: insertionPoint.y - app.frame.minY)).tap() name.typeText(XCUIKeyboardKey.delete.rawValue) - name.typeText("Meera Maker\n") + XCTAssertEqual(name.value as? String, String(original.dropLast()), "The first native deletion must prove the insertion point is at the end.") + name.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: original.count - 1)) + name.typeText("Meera Maker") XCTAssertEqual(name.value as? String, "Meera Maker", "Verify the replacement before testing rotation preservation.") + XCTAssertTrue(done.isEnabled && done.isHittable) + XCTAssertGreaterThan(done.frame.width, 0); XCTAssertGreaterThan(done.frame.height, 0) + XCTAssertTrue(app.frame.insetBy(dx: 2, dy: 2).contains(done.frame)) + capture("Native profile keyboard action") + done.tap() + XCTAssertTrue(app.keyboards.firstMatch.waitForNonExistence(timeout: 5)) + XCTAssertEqual(name.value as? String, "Meera Maker") app.rotateToLandscapeAndWait() XCTAssertGreaterThan(app.frame.width, app.frame.height); XCTAssertEqual(name.value as? String, "Meera Maker") tap(app.buttons["Save changes"], in: app); XCTAssertTrue(app.staticTexts["Profile saved."].waitForExistence(timeout: 5)); capture("Native profile saved landscape") diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cf142a2..6026934 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,12 @@ ## B5 Complete native control activation and accessibility headings — correction checkpoint (2026-10-18) +The `6801353` full-matrix phone run passed 302 native cases and failed two of 37 UI journeys. The event journey stayed on Connect after its Explore action; registration never began. Its demo helper now requires the actual role and sign-in destinations before issuing the existing bounded navigation gestures. The separate canonical tablet run passed 302 native cases and failed one of 37 UI journeys while awaiting Profile/sidebar setup after choosing a makerspace. Actual video eventually showed Home; shallow hierarchy exports and differing video/test durations do not establish a dispatch or sidebar cause. Account setup now requires the real Home navigation destination before its unchanged Profile/sidebar predicates. Neither change retries a tap or claims a production callback defect. + +The same source's compact partial log separately reported nine phone digits after one ten-digit native typing call; its exact value assertion stopped the journey before sending a code. Terminal compact counts and actual media were not yet available when this correction was prepared, and no product input cause is claimed. That test now sends each of the ten intended digits exactly once through the public native typing API, requiring the actual expected prefix within five seconds before entering the next digit. It retains the final exact number, real Done/keyboard-dismissal, verification and session-revocation assertions. No digit is resent, no sleep or input hook is added, and the application's direct phone binding is unchanged. + +The phone profile test's actual triple-tap point lay after the short name, so no text was selected and its first delete removed only the last letter. Its exact pre-rotation assertion rejected the retained prefix. Actual pixels also showed the native floating keyboard Done accessory overlapping the field's trailing editing area. Profile editing now uses the same focus-bound native navigation Done pattern as account forms, with a stable accessibility identifier; its existing focus clearing and submit behavior remain. The journey activates the visible trailing editing interior, requires its first native deletion to produce the exact original prefix before deleting the remaining bounded characters, then keeps the exact replacement before and after rotation and all save/preferences outcomes. It captures and natively activates the actual Done item and requires keyboard dismissal. No clipboard, hidden state, caret assumption, edit-menu availability requirement or test retry is introduced. Existing three-second Profile/sidebar predicates, 24-gesture budgets and all outcome assertions remain; explicit bounded destination checks run before them. Intended totals remain 302 native/37 UI/42 Python; all `6801353` results remain diagnostic and fresh exact-source native/Release and actual visual gates are required. + The `7ff1398` canonical phone run passed 302 native cases and 36 of 37 UI journeys, but its profile test's long press never exposed a native Select All menu. Actual recording frames and the final hierarchy showed the original focused name and keyboard without any edit-menu node; replacement and rotation had not started. That test now verifies the original name, uses Apple's documented one-finger triple-tap paragraph selection and a native delete, then retains its exact replacement assertion before the unchanged rotation, save and preferences outcomes. Any retained prefix or suffix fails that assertion before rotation; no empty-field placeholder assumption, clipboard, caret-position assumption, application hook or retry is added. Separately, the two initial tablet failures occurred inside XCTest launch background-assertion acquisition, before account/deletion actions; no product cause or source remedy is claimed for those launcher failures. The same source's actual largest-text phone heading still broke Notifications before its final letter despite the vertical decorative bell. The live and demo notification pages now use 12-point outer insets and 12-point heading-card content padding only at accessibility sizes, recovering width for the full-scale title. `SWCard` keeps its ordinary 24-point default through an explicit builder initializer; every other card and normal-size notification inset is unchanged. Font, heading semantics, symbol, text, colors and callbacks remain. Fresh compact and phone pixels must prove the complete word fits; no font cap or truncation is introduced. Intended totals remain 302 native/37 UI/42 Python, and the failed `7ff1398` canonical run is diagnostic rather than final acceptance. From 0267d4c94a3c83a012e74983b689a25223d1a919 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 08:48:24 +0530 Subject: [PATCH 71/98] fix(b5): use native inline notification titles at accessibility sizes Co-Authored-By: Codex --- SpaceWorks/DemoNotificationsView.swift | 1 + SpaceWorks/NotificationSettingsView.swift | 1 + docs/CHANGELOG.md | 2 ++ 3 files changed, 4 insertions(+) diff --git a/SpaceWorks/DemoNotificationsView.swift b/SpaceWorks/DemoNotificationsView.swift index 6e17ae0..0454aae 100644 --- a/SpaceWorks/DemoNotificationsView.swift +++ b/SpaceWorks/DemoNotificationsView.swift @@ -52,6 +52,7 @@ struct DemoNotificationsView: View { }.disabled(!state.enabled || state.permissionDenied) }.padding(dynamicTypeSize.isAccessibilitySize ? 12 : 24).frame(maxWidth: 640).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle("Notifications") + .navigationBarTitleDisplayMode(dynamicTypeSize.isAccessibilitySize ? .inline : .automatic) } } #endif diff --git a/SpaceWorks/NotificationSettingsView.swift b/SpaceWorks/NotificationSettingsView.swift index 0a18988..97140a0 100644 --- a/SpaceWorks/NotificationSettingsView.swift +++ b/SpaceWorks/NotificationSettingsView.swift @@ -48,6 +48,7 @@ struct NotificationSettingsView: View { SWCard { Text("Choose which updates you receive in Communication preferences. Required safety messages remain available in your inbox.").foregroundStyle(Color.swMuted) } }.padding(dynamicTypeSize.isAccessibilitySize ? 12 : 24).frame(maxWidth: 640).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle("Notifications") + .navigationBarTitleDisplayMode(dynamicTypeSize.isAccessibilitySize ? .inline : .automatic) .task { await state.prepare() } } } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6026934..892940f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## B5 Complete native control activation and accessibility headings — correction checkpoint (2026-10-18) +Subsequent actual `6801353` compact evidence passed 302 native cases and failed one of 37 UI journeys at the immediate phone-value assertion. Later recorded frames show the complete ten-digit number, so no dropped-digit product defect is established; the reviewed per-digit readiness checks retain exact native values. Its actual largest-text notification capture shows the inner card heading as a complete word after the inset correction, while the native large page title is ellipsized. Both live and demo notification pages now request the platform's inline navigation-title mode only at accessibility text sizes; ordinary automatic title behavior and full-scale card typography remain. No custom title, font cap, text change, callback or test case is added. Fresh compact and other profile PNGs must establish complete native inline-title fit; the older failed source and intermediate `1d0e4a7` successor provide no final acceptance. Intended totals remain 302 native/37 UI/42 Python. + The `6801353` full-matrix phone run passed 302 native cases and failed two of 37 UI journeys. The event journey stayed on Connect after its Explore action; registration never began. Its demo helper now requires the actual role and sign-in destinations before issuing the existing bounded navigation gestures. The separate canonical tablet run passed 302 native cases and failed one of 37 UI journeys while awaiting Profile/sidebar setup after choosing a makerspace. Actual video eventually showed Home; shallow hierarchy exports and differing video/test durations do not establish a dispatch or sidebar cause. Account setup now requires the real Home navigation destination before its unchanged Profile/sidebar predicates. Neither change retries a tap or claims a production callback defect. The same source's compact partial log separately reported nine phone digits after one ten-digit native typing call; its exact value assertion stopped the journey before sending a code. Terminal compact counts and actual media were not yet available when this correction was prepared, and no product input cause is claimed. That test now sends each of the ten intended digits exactly once through the public native typing API, requiring the actual expected prefix within five seconds before entering the next digit. It retains the final exact number, real Done/keyboard-dismissal, verification and session-revocation assertions. No digit is resent, no sleep or input hook is added, and the application's direct phone binding is unchanged. From 3fe68c353ea58b993a951507e5bf551b23e296ee Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:50:18 +0530 Subject: [PATCH 72/98] test(b5): synchronize native profile editing and sidebar readiness Co-Authored-By: Codex --- SpaceWorksUITests/AccountFlowTests.swift | 7 ++++++- SpaceWorksUITests/MemberActionsFlowTests.swift | 6 +++--- docs/CHANGELOG.md | 2 ++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/SpaceWorksUITests/AccountFlowTests.swift b/SpaceWorksUITests/AccountFlowTests.swift index 5a85ae3..6658379 100644 --- a/SpaceWorksUITests/AccountFlowTests.swift +++ b/SpaceWorksUITests/AccountFlowTests.swift @@ -22,7 +22,12 @@ final class AccountFlowTests: XCTestCase { XCTAssertEqual(XCTWaiter.wait(for: [sidebarReady], timeout: 3), .completed) sidebar.tap() let opened = XCTNSPredicateExpectation(predicate: readiness, object: profile) - XCTAssertEqual(XCTWaiter.wait(for: [opened], timeout: 3), .completed) + let openedResult = XCTWaiter.wait(for: [opened], timeout: 10) + if openedResult != .completed { + let exists = profile.exists + print("Profile after native sidebar: exists=\(exists), enabled=\(exists && profile.isEnabled), hittable=\(exists && profile.isHittable), frame=\(exists ? String(describing: profile.frame) : "unavailable")") + } + XCTAssertEqual(openedResult, .completed) } tap(profile, in: app) } diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index 6ccc2a7..238b6f5 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -286,9 +286,9 @@ final class MemberActionsFlowTests: XCTestCase { let viewport = visibleContent(containingScroll(name, app), app, gestureInset: 0).insetBy(dx: 2, dy: 2) XCTAssertTrue(viewport.contains(insertionPoint)) app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: insertionPoint.x - app.frame.minX, dy: insertionPoint.y - app.frame.minY)).tap() - name.typeText(XCUIKeyboardKey.delete.rawValue) - XCTAssertEqual(name.value as? String, String(original.dropLast()), "The first native deletion must prove the insertion point is at the end.") - name.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: original.count - 1)) + // A native text interaction may select the final word. Clear the bounded + // original length, then require the complete intended replacement. + name.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: original.count)) name.typeText("Meera Maker") XCTAssertEqual(name.value as? String, "Meera Maker", "Verify the replacement before testing rotation preservation.") XCTAssertTrue(done.isEnabled && done.isHittable) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 892940f..02918d7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## B5 Complete native control activation and accessibility headings — correction checkpoint (2026-10-18) +The actual `0267d4c` full-matrix phone run passed 302 native cases and failed one of 37 UI journeys at an extra first-delete assertion. Finer recording frames show the native editor selecting the final word, followed by one Delete removing that selection; requiring exactly one character to disappear overconstrained valid native editing. The test now sends one bounded sequence of ten native Delete keys for the known ten-character original, then retains the exact replacement before and after keyboard dismissal/rotation and all save/preferences outcomes. No empty-field placeholder assumption, selection API, input retry or application change is added. The same-source canonical tablet run passed 302 native cases and failed two of 37 UI journeys: that profile assertion and a post-sidebar readiness predicate. Its actual recording shows the sidebar opening and a complete Profile row, while the three-second predicate obtained only one expensive accessibility evaluation before timing out. Only that post-toggle readiness bound now matches the existing ten-second Home destination allowance, with individual existence/enabled/hittable/frame diagnostics on failure. Initial Profile/sidebar predicates, native activation, 24-gesture budgets, case counts and all destination/outcome assertions remain; no production navigation cause is established. Fresh exact-source canonical, six-profile and actual visual gates remain required. + Subsequent actual `6801353` compact evidence passed 302 native cases and failed one of 37 UI journeys at the immediate phone-value assertion. Later recorded frames show the complete ten-digit number, so no dropped-digit product defect is established; the reviewed per-digit readiness checks retain exact native values. Its actual largest-text notification capture shows the inner card heading as a complete word after the inset correction, while the native large page title is ellipsized. Both live and demo notification pages now request the platform's inline navigation-title mode only at accessibility text sizes; ordinary automatic title behavior and full-scale card typography remain. No custom title, font cap, text change, callback or test case is added. Fresh compact and other profile PNGs must establish complete native inline-title fit; the older failed source and intermediate `1d0e4a7` successor provide no final acceptance. Intended totals remain 302 native/37 UI/42 Python. The `6801353` full-matrix phone run passed 302 native cases and failed two of 37 UI journeys. The event journey stayed on Connect after its Explore action; registration never began. Its demo helper now requires the actual role and sign-in destinations before issuing the existing bounded navigation gestures. The separate canonical tablet run passed 302 native cases and failed one of 37 UI journeys while awaiting Profile/sidebar setup after choosing a makerspace. Actual video eventually showed Home; shallow hierarchy exports and differing video/test durations do not establish a dispatch or sidebar cause. Account setup now requires the real Home navigation destination before its unchanged Profile/sidebar predicates. Neither change retries a tap or claims a production callback defect. From 739af7741d7b144bf320f5aca70895378260c2dc Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:04:19 +0530 Subject: [PATCH 73/98] test(b5): wait for native sidebar and system share readiness Co-Authored-By: Codex --- SpaceWorksUITests/AccountFlowTests.swift | 2 +- SpaceWorksUITests/MemberActionsFlowTests.swift | 9 +++++++-- docs/CHANGELOG.md | 2 ++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/SpaceWorksUITests/AccountFlowTests.swift b/SpaceWorksUITests/AccountFlowTests.swift index 6658379..5e8e134 100644 --- a/SpaceWorksUITests/AccountFlowTests.swift +++ b/SpaceWorksUITests/AccountFlowTests.swift @@ -198,7 +198,7 @@ final class AccountFlowTests: XCTestCase { // NativeUIActions requires a real visible/hittable action after bounded scrolling. tap(app.buttons["account-share-export"], in: app) let systemSave = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Save to Files")).firstMatch - XCTAssertTrue(systemSave.waitForExistence(timeout: 5), "The actual native share sheet must expose a system file action.") + XCTAssertTrue(systemSave.waitForExistence(timeout: 45), "The actual native share sheet must expose a system file action.") attach("Native archive share largest text landscape") } func testGlobalPasswordAndDevicesWorkBeforeJoiningAMakerspace() { diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index 238b6f5..61b5467 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -219,10 +219,15 @@ final class MemberActionsFlowTests: XCTestCase { private func tab(_ app: XCUIApplication, _ name: String) { if app.tabBars.buttons[name].exists { app.tabBars.buttons[name].tap(); return } let item = app.buttons["nav-\(name)"] - let ready = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == true AND isHittable == true"), object: item) + let readiness = NSPredicate(format: "exists == true AND isEnabled == true AND isHittable == true") + let ready = XCTNSPredicateExpectation(predicate: readiness, object: item) if XCTWaiter.wait(for: [ready], timeout: 3) != .completed { let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label ==[c] %@ OR label ==[c] %@", "Show Sidebar", "Toggle Sidebar")).firstMatch - if sidebar.exists && sidebar.isHittable { sidebar.tap() } + let sidebarReady = XCTNSPredicateExpectation(predicate: readiness, object: sidebar) + XCTAssertEqual(XCTWaiter.wait(for: [sidebarReady], timeout: 10), .completed) + sidebar.tap() + let opened = XCTNSPredicateExpectation(predicate: readiness, object: item) + XCTAssertEqual(XCTWaiter.wait(for: [opened], timeout: 10), .completed) } tap(item, in: app) } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 02918d7..1bb4d95 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## B5 Complete native control activation and accessibility headings — correction checkpoint (2026-10-18) +The later terminal `0267d4c` full-tablet artifact passed 302 native cases and failed four of 37 UI journeys, including the already recorded profile guard. Its export recording eventually shows the complete native Save to Files action after the five-second assertion had expired; only that system-action existence allowance is now 45 seconds, with one unchanged Share tap and the same required native action/capture. The booking journey never reached Find a space: after Show Sidebar it searched for the absent Activity navigation item by dragging Home. That test's exact native sidebar control now must exist, be enabled and hittable within ten seconds before its single tap, followed by the same readiness requirements on the actual requested navigation item before the existing activation. The initial three-second navigation check, 24-gesture content bound and all booking outcomes remain. The separate membership failure was its five-second alert-dismissal assertion after a correctly targeted native action; recorded labels fit and no callback cause is established, so its application code and dismissal assertion remain unchanged. All parent results are diagnostic; fresh exact-source native/Release and actual visual gates remain mandatory. Intended totals remain 302 native/37 UI/42 Python. + The actual `0267d4c` full-matrix phone run passed 302 native cases and failed one of 37 UI journeys at an extra first-delete assertion. Finer recording frames show the native editor selecting the final word, followed by one Delete removing that selection; requiring exactly one character to disappear overconstrained valid native editing. The test now sends one bounded sequence of ten native Delete keys for the known ten-character original, then retains the exact replacement before and after keyboard dismissal/rotation and all save/preferences outcomes. No empty-field placeholder assumption, selection API, input retry or application change is added. The same-source canonical tablet run passed 302 native cases and failed two of 37 UI journeys: that profile assertion and a post-sidebar readiness predicate. Its actual recording shows the sidebar opening and a complete Profile row, while the three-second predicate obtained only one expensive accessibility evaluation before timing out. Only that post-toggle readiness bound now matches the existing ten-second Home destination allowance, with individual existence/enabled/hittable/frame diagnostics on failure. Initial Profile/sidebar predicates, native activation, 24-gesture budgets, case counts and all destination/outcome assertions remain; no production navigation cause is established. Fresh exact-source canonical, six-profile and actual visual gates remain required. Subsequent actual `6801353` compact evidence passed 302 native cases and failed one of 37 UI journeys at the immediate phone-value assertion. Later recorded frames show the complete ten-digit number, so no dropped-digit product defect is established; the reviewed per-digit readiness checks retain exact native values. Its actual largest-text notification capture shows the inner card heading as a complete word after the inset correction, while the native large page title is ellipsized. Both live and demo notification pages now request the platform's inline navigation-title mode only at accessibility text sizes; ordinary automatic title behavior and full-scale card typography remain. No custom title, font cap, text change, callback or test case is added. Fresh compact and other profile PNGs must establish complete native inline-title fit; the older failed source and intermediate `1d0e4a7` successor provide no final acceptance. Intended totals remain 302 native/37 UI/42 Python. From 47e1880a5c7fe549608e2a38cba789d47cf152dc Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:48:16 +0530 Subject: [PATCH 74/98] chore(b6): capture durable native orientation diagnostics Co-Authored-By: Codex --- .github/workflows/ios.yml | 23 +++++++++++++++---- .../NativeOrientationDiagnostics.swift | 12 +++++++++- docs/CHANGELOG.md | 6 +++++ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 9be5722..5598239 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -3,6 +3,11 @@ on: push: pull_request: workflow_dispatch: + inputs: + orientation_diagnostic: + description: Read-only tablet landscape diagnostic (one existing UI journey; not final acceptance) + type: boolean + default: false permissions: contents: read concurrency: @@ -13,13 +18,14 @@ jobs: runs-on: macos-15 env: DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer + ORIENTATION_DIAGNOSTIC: ${{ github.event_name == 'workflow_dispatch' && inputs.orientation_diagnostic && 'true' || 'false' }} timeout-minutes: 120 name: Build and test (${{ matrix.device }}) strategy: fail-fast: false max-parallel: 6 matrix: - device: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["compact","phone","large","tablet","tablet-landscape","large-tablet"]' || '["phone","tablet"]') }} + device: ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.orientation_diagnostic && '["tablet-landscape"]' || '["compact","phone","large","tablet","tablet-landscape","large-tablet"]') || '["phone","tablet"]') }} steps: - uses: actions/checkout@v4 with: @@ -89,7 +95,10 @@ jobs: 'phone_and_tablet_families': set(info.get('UIDeviceFamily', [])) == {1, 2}, 'phone_orientations': required <= set(info.get('UISupportedInterfaceOrientations~iphone', info.get('UISupportedInterfaceOrientations', []))), 'tablet_orientations': required <= set(info.get('UISupportedInterfaceOrientations~ipad', []))} - report = {'kind': 'canonical-built-debug-metadata', 'source_sha': source, + diagnostic = os.environ['ORIENTATION_DIAGNOSTIC'] == 'true' + report = {'kind': 'orientation-diagnostic-built-debug-metadata' if diagnostic else 'canonical-built-debug-metadata', 'source_sha': source, + 'purpose': 'read-only-orientation-diagnostic' if diagnostic else 'complete-native-validation', + 'ui_selection': 'SpaceWorksUITests/MainFlowTests/testRegularTextDarkLandscapeSignInUsesNativeBrandPanel' if diagnostic else 'SpaceWorksUITests', 'profile': os.environ['DEVICE_PROFILE'], 'bundle_id': info.get('CFBundleIdentifier'), 'sdk': info.get('DTSDKName'), 'device_families': info.get('UIDeviceFamily'), 'fonts': info.get('UIAppFonts', []), 'scene_manifest': scene, @@ -105,7 +114,11 @@ jobs: - name: Run native UI journeys run: | set -o pipefail - xcodebuild test-without-building -project SpaceWorks.xcodeproj -scheme SpaceWorks -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -resultBundlePath TestResults.xcresult -skipPackagePluginValidation "SPACEWORKS_TEST_PROFILE=$DEVICE_PROFILE" ONLY_ACTIVE_ARCH=YES CODE_SIGN_STYLE=Manual CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= -only-testing:SpaceWorksUITests | tee -a build.log + UI_SELECTION=SpaceWorksUITests + if [ "$ORIENTATION_DIAGNOSTIC" = true ]; then + UI_SELECTION=SpaceWorksUITests/MainFlowTests/testRegularTextDarkLandscapeSignInUsesNativeBrandPanel + fi + xcodebuild test-without-building -project SpaceWorks.xcodeproj -scheme SpaceWorks -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -resultBundlePath TestResults.xcresult -skipPackagePluginValidation "SPACEWORKS_TEST_PROFILE=$DEVICE_PROFILE" ONLY_ACTIVE_ARCH=YES CODE_SIGN_STYLE=Manual CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= "-only-testing:$UI_SELECTION" | tee -a build.log - name: Export UI screenshots if: always() run: | @@ -124,13 +137,13 @@ jobs: if: always() run: | if [ -n "$SIMULATOR_ID" ]; then - xcrun simctl spawn "$SIMULATOR_ID" log show --last 120m --style compact --predicate 'subsystem == "org.spaceworks.orientation-diagnostics" AND category == "orientation"' > orientation-system.log 2>&1 || true + xcrun simctl spawn "$SIMULATOR_ID" log show --last 120m --style compact --info --predicate 'subsystem == "org.spaceworks.orientation-diagnostics" AND category == "orientation"' > orientation-system.log 2>&1 || true fi - name: Archive validation evidence if: always() uses: actions/upload-artifact@v4 with: - name: ios-validation-${{ matrix.device }} + name: ${{ env.ORIENTATION_DIAGNOSTIC == 'true' && 'ios-orientation-diagnostic' || 'ios-validation' }}-${{ matrix.device }} path: | NativeTestResults.xcresult TestResults.xcresult diff --git a/SpaceWorks/Platform/NativeOrientationDiagnostics.swift b/SpaceWorks/Platform/NativeOrientationDiagnostics.swift index 6adcbbe..29fa0c6 100644 --- a/SpaceWorks/Platform/NativeOrientationDiagnostics.swift +++ b/SpaceWorks/Platform/NativeOrientationDiagnostics.swift @@ -122,10 +122,20 @@ private final class OrientationObserverBag { "device_idiom": UIDevice.current.userInterfaceIdiom.rawValue, "scene_interface_orientation": scene.interfaceOrientation.rawValue, "scene_activation": scene.activationState.rawValue, + "application_supported_mask": UIApplication.shared.supportedInterfaceOrientations(for: window).rawValue, "host_window_frame": geometry(window.frame), "scene_bounds": geometry(scene.coordinateSpace.bounds), "screen_bounds": geometry(window.screen.bounds), "windows": windows, "controller_limit_reached": remaining == 0] + if #available(iOS 26.0, *) { + let effective = scene.effectiveGeometry + record["effective_geometry"] = [ + "interface_orientation": effective.interfaceOrientation.rawValue, + "coordinate_bounds": geometry(effective.coordinateSpace.bounds), + "interface_orientation_locked": effective.isInterfaceOrientationLocked, + "interactively_resizing": effective.isInteractivelyResizing + ] as [String: Any] + } guard let fingerprint = try? JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) else { return } // Keep actual notifications even if policy is unchanged; suppress layout-only duplicates. if !reason.hasPrefix("notification:"), fingerprint == lastState { return } @@ -135,7 +145,7 @@ private final class OrientationObserverBag { guard let bytes = try? JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]), let text = String(data: bytes, encoding: .utf8) else { return } print("SpaceWorksOrientation " + text) - Self.logger.info("SpaceWorksOrientation \(text, privacy: .public)") + Self.logger.notice("SpaceWorksOrientation \(text, privacy: .public)") } private func geometry(_ frame: CGRect) -> [Any] { [frame.minX, frame.minY, frame.width, frame.height].map { value in diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1bb4d95..017570d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,11 @@ # Phase notes +## B6 Durable read-only orientation evidence — diagnostic checkpoint (2026-10-18) + +The actual `739af77` tablet-landscape run passed all 302 native cases but failed all 37 UI journeys at their unchanged ten-second orientation guards. Full-OS recordings remained portrait despite the physical device orientation request; actual Debug metadata declares both device families, all required orientations and single-scene ownership. No application orientation cause is established. The diagnostic logger previously used Info while collection omitted `--info`; its empty collected output cannot establish that the probe failed to attach. The double-gated Debug probe now records at Notice, retains the narrow subsystem/category collection with Info included, and reads the application's supported mask and iOS 26 effective scene geometry without requesting geometry or changing window policy. + +An explicitly labeled, opt-in workflow-dispatch diagnostic runs only tablet-landscape and the existing regular-text dark landscape sign-in journey, while retaining the complete native state/API suite, whole-scheme compilation, actual Debug metadata and evidence archive. Its metadata kind, purpose and artifact prefix distinguish it from final acceptance. Default push/pull-request validation still runs phone/tablet and all 37 UI cases; default manual validation still runs all six profiles and all 37 cases. No test, timeout, rotation assertion or Release behavior is relaxed. This focused run supplies diagnostic evidence only; fresh exact-source complete native/Release and actual visual gates remain required before delivery. + ## B5 Complete native control activation and accessibility headings — correction checkpoint (2026-10-18) The later terminal `0267d4c` full-tablet artifact passed 302 native cases and failed four of 37 UI journeys, including the already recorded profile guard. Its export recording eventually shows the complete native Save to Files action after the five-second assertion had expired; only that system-action existence allowance is now 45 seconds, with one unchanged Share tap and the same required native action/capture. The booking journey never reached Find a space: after Show Sidebar it searched for the absent Activity navigation item by dragging Home. That test's exact native sidebar control now must exist, be enabled and hittable within ten seconds before its single tap, followed by the same readiness requirements on the actual requested navigation item before the existing activation. The initial three-second navigation check, 24-gesture content bound and all booking outcomes remain. The separate membership failure was its five-second alert-dismissal assertion after a correctly targeted native action; recorded labels fit and no callback cause is established, so its application code and dismissal assertion remain unchanged. All parent results are diagnostic; fresh exact-source native/Release and actual visual gates remain mandatory. Intended totals remain 302 native/37 UI/42 Python. From 775f2980d2da7c8a1412024b003d485fe33cc2b7 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:02:04 +0530 Subject: [PATCH 75/98] test(b6): keep sign-in reveal within the content gutter Co-Authored-By: Codex --- SpaceWorksUITests/MainFlowTests.swift | 2 +- docs/CHANGELOG.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/SpaceWorksUITests/MainFlowTests.swift b/SpaceWorksUITests/MainFlowTests.swift index 71ae409..f89e5b5 100644 --- a/SpaceWorksUITests/MainFlowTests.swift +++ b/SpaceWorksUITests/MainFlowTests.swift @@ -121,7 +121,7 @@ final class MainFlowTests: XCTestCase { let adjustmentLimit = min(120, viewport.height * 0.4) if abs(translation) <= adjustmentLimit { let movement = (translation < 0 ? -1.0 : 1.0) * min(max(abs(translation), 12), adjustmentLimit) - let x = viewport.minX + 12 + let x = min(max(actionsFrame.minX - 12, viewport.minX + 12), viewport.maxX - 12) let startY = viewport.midY - movement / 2 let endY = viewport.midY + movement / 2 let dragFrame = CGRect(x: x - 2, y: min(startY, endY) - 2, width: 4, height: abs(movement) + 4) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 017570d..b27c211 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,8 @@ The actual `739af77` tablet-landscape run passed all 302 native cases but failed An explicitly labeled, opt-in workflow-dispatch diagnostic runs only tablet-landscape and the existing regular-text dark landscape sign-in journey, while retaining the complete native state/API suite, whole-scheme compilation, actual Debug metadata and evidence archive. Its metadata kind, purpose and artifact prefix distinguish it from final acceptance. Default push/pull-request validation still runs phone/tablet and all 37 UI cases; default manual validation still runs all six profiles and all 37 cases. No test, timeout, rotation assertion or Release behavior is relaxed. This focused run supplies diagnostic evidence only; fresh exact-source complete native/Release and actual visual gates remain required before delivery. +The interrupted `739af77` large-phone log separately records a largest-text landscape sign-in reveal failure: its two actions fit together within the visible viewport, but repeated short drags at the full-window left gutter left their frames unchanged, with Create account extending below the visible boundary. That test now derives the short-drag gutter from the actual actions' leading edge, twelve points into their card's existing 24-point content padding, clamped within the viewport's twelve-point margins. All full-action bounds, interactive-control collision checks, native gestures, the 24-attempt bound and downstream outcomes remain. No application, font, timeout or assertion change is added. The cancelled artifact did not export a UI recording, so no visually confirmed hit-routing cause is claimed; actual new-source control framing remains required. + ## B5 Complete native control activation and accessibility headings — correction checkpoint (2026-10-18) The later terminal `0267d4c` full-tablet artifact passed 302 native cases and failed four of 37 UI journeys, including the already recorded profile guard. Its export recording eventually shows the complete native Save to Files action after the five-second assertion had expired; only that system-action existence allowance is now 45 seconds, with one unchanged Share tap and the same required native action/capture. The booking journey never reached Find a space: after Show Sidebar it searched for the absent Activity navigation item by dragging Home. That test's exact native sidebar control now must exist, be enabled and hittable within ten seconds before its single tap, followed by the same readiness requirements on the actual requested navigation item before the existing activation. The initial three-second navigation check, 24-gesture content bound and all booking outcomes remain. The separate membership failure was its five-second alert-dismissal assertion after a correctly targeted native action; recorded labels fit and no callback cause is established, so its application code and dismissal assertion remain unchanged. All parent results are diagnostic; fresh exact-source native/Release and actual visual gates remain mandatory. Intended totals remain 302 native/37 UI/42 Python. From 2ba7ec4b1470d872e1ac910c2348e727976b60fc Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:21:39 +0530 Subject: [PATCH 76/98] chore(c1): harden build inputs and document native app handoff Co-Authored-By: Codex --- .github/workflows/ios.yml | 46 ++++--- .gitignore | 10 ++ AGENTS.md | 7 +- CONTRIBUTING.md | 34 +++++ Dependencies/Package.resolved | 186 ++++++++++++++++++++++++++ Dependencies/README.md | 33 +++++ Dependencies/build-tools.json | 12 ++ README.md | 81 +++++++++-- SECURITY.md | 21 +++ docs/CHANGELOG.md | 6 + docs/IMPLEMENTATION-PLAN.md | 5 +- docs/RELEASE-READINESS.md | 12 +- docs/STORE-LISTING.md | 4 +- docs/images/inventory.png | Bin 0 -> 354968 bytes docs/images/ipad-profile.png | Bin 0 -> 531519 bytes docs/images/ipad-sign-in.png | Bin 0 -> 276122 bytes docs/images/onboarding.png | Bin 0 -> 165653 bytes docs/images/provenance.json | 99 ++++++++++++++ docs/images/request-review.png | Bin 0 -> 217561 bytes docs/images/spaceworks-icon.png | Bin 0 -> 32572 bytes docs/mobile-auth-ux-spec.md | 2 +- scripts/check_build_tools.py | 27 ++++ scripts/dependency_lock.py | 59 ++++++++ scripts/tests/test_dependency_lock.py | 39 ++++++ 24 files changed, 643 insertions(+), 40 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 Dependencies/Package.resolved create mode 100644 Dependencies/README.md create mode 100644 Dependencies/build-tools.json create mode 100644 SECURITY.md create mode 100644 docs/images/inventory.png create mode 100644 docs/images/ipad-profile.png create mode 100644 docs/images/ipad-sign-in.png create mode 100644 docs/images/onboarding.png create mode 100644 docs/images/provenance.json create mode 100644 docs/images/request-review.png create mode 100644 docs/images/spaceworks-icon.png create mode 100644 scripts/check_build_tools.py create mode 100644 scripts/dependency_lock.py create mode 100644 scripts/tests/test_dependency_lock.py diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 5598239..c5946f6 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -27,22 +27,25 @@ jobs: matrix: device: ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.orientation_diagnostic && '["tablet-landscape"]' || '["compact","phone","large","tablet","tablet-landscape","large-tablet"]') || '["phone","tablet"]') }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4, verified upstream and actual focused47 job with: submodules: recursive + persist-credentials: false - name: Verify pinned Xcode and SDK run: | xcodebuild -version xcrun --sdk iphonesimulator --show-sdk-version xcrun --sdk iphoneos --show-sdk-version - name: Install project and icon generators - run: brew install xcodegen librsvg + run: | + brew install xcodegen librsvg + python3 scripts/check_build_tools.py - name: Restore Swift package sources id: package-cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: SourcePackages - key: ${{ runner.os }}-${{ runner.arch }}-spm-v2-${{ hashFiles('project.yml', 'Packages/**/Package.swift') }} + key: ${{ runner.os }}-${{ runner.arch }}-spm-v2-${{ hashFiles('project.yml', 'Packages/**/Package.swift', 'Dependencies/Package.resolved') }} restore-keys: | ${{ runner.os }}-${{ runner.arch }}-spm-v2- ${{ runner.os }}-${{ runner.arch }}-spm- @@ -56,29 +59,32 @@ jobs: echo "identity=$XCODE_BUILD-$SIM_SDK_BUILD-$DEVICE_SDK_BUILD" >> "$GITHUB_OUTPUT" - name: Cache compiled dependencies and build intermediates id: compiled-cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | DerivedData/Build DerivedData/ModuleCache.noindex DerivedData/SDKStatCaches.noindex - key: ios-build-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.xcode-cache.outputs.identity }}-${{ hashFiles('project.yml', 'Packages/**/Package.swift') }}-${{ matrix.device }}-${{ github.sha }} + key: ios-build-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.xcode-cache.outputs.identity }}-${{ hashFiles('project.yml', 'Packages/**/Package.swift', 'Dependencies/Package.resolved') }}-${{ matrix.device }}-${{ github.sha }} restore-keys: | - ios-build-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.xcode-cache.outputs.identity }}-${{ hashFiles('project.yml', 'Packages/**/Package.swift') }}-${{ matrix.device }}- - ios-build-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.xcode-cache.outputs.identity }}-${{ hashFiles('project.yml', 'Packages/**/Package.swift') }}- + ios-build-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.xcode-cache.outputs.identity }}-${{ hashFiles('project.yml', 'Packages/**/Package.swift', 'Dependencies/Package.resolved') }}-${{ matrix.device }}- + ios-build-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.xcode-cache.outputs.identity }}-${{ hashFiles('project.yml', 'Packages/**/Package.swift', 'Dependencies/Package.resolved') }}- - name: Generate and validate source inputs run: | python3 scripts/generate.py python3 -m unittest discover -s scripts/tests -v python3 scripts/lint.py xcodegen generate + python3 scripts/dependency_lock.py stage - name: Select phone or tablet simulator run: | python3 scripts/device-matrix.py '${{ matrix.device }}' - name: Compile app and native test bundles run: | set -o pipefail - xcodebuild build-for-testing -project SpaceWorks.xcodeproj -scheme SpaceWorks -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -skipPackagePluginValidation "SPACEWORKS_TEST_PROFILE=$DEVICE_PROFILE" ONLY_ACTIVE_ARCH=YES CODE_SIGN_STYLE=Manual CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= | tee build.log + xcodebuild build-for-testing -project SpaceWorks.xcodeproj -scheme SpaceWorks -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -disableAutomaticPackageResolution -onlyUsePackageVersionsFromResolvedFile -skipPackagePluginValidation "SPACEWORKS_TEST_PROFILE=$DEVICE_PROFILE" ONLY_ACTIVE_ARCH=YES CODE_SIGN_STYLE=Manual CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= | tee build.log + - name: Verify reviewed dependency lock + run: python3 scripts/dependency_lock.py check - name: Inspect actual built Debug bundle metadata run: | python3 - <<'PY' @@ -96,7 +102,8 @@ jobs: 'phone_orientations': required <= set(info.get('UISupportedInterfaceOrientations~iphone', info.get('UISupportedInterfaceOrientations', []))), 'tablet_orientations': required <= set(info.get('UISupportedInterfaceOrientations~ipad', []))} diagnostic = os.environ['ORIENTATION_DIAGNOSTIC'] == 'true' - report = {'kind': 'orientation-diagnostic-built-debug-metadata' if diagnostic else 'canonical-built-debug-metadata', 'source_sha': source, + dependency_lock = json.loads(pathlib.Path('dependency-lock-validation.json').read_text(encoding='utf-8')) + report = {'dependency_lock': dependency_lock, 'kind': 'orientation-diagnostic-built-debug-metadata' if diagnostic else 'canonical-built-debug-metadata', 'source_sha': source, 'purpose': 'read-only-orientation-diagnostic' if diagnostic else 'complete-native-validation', 'ui_selection': 'SpaceWorksUITests/MainFlowTests/testRegularTextDarkLandscapeSignInUsesNativeBrandPanel' if diagnostic else 'SpaceWorksUITests', 'profile': os.environ['DEVICE_PROFILE'], 'bundle_id': info.get('CFBundleIdentifier'), @@ -110,7 +117,7 @@ jobs: timeout-minutes: 10 run: | set -o pipefail - xcodebuild test-without-building -project SpaceWorks.xcodeproj -scheme SpaceWorks -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -resultBundlePath NativeTestResults.xcresult -skipPackagePluginValidation "SPACEWORKS_TEST_PROFILE=$DEVICE_PROFILE" ONLY_ACTIVE_ARCH=YES CODE_SIGN_STYLE=Manual CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= -only-testing:SpaceWorksTests | tee -a build.log + xcodebuild test-without-building -project SpaceWorks.xcodeproj -scheme SpaceWorks -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -disableAutomaticPackageResolution -onlyUsePackageVersionsFromResolvedFile -resultBundlePath NativeTestResults.xcresult -skipPackagePluginValidation "SPACEWORKS_TEST_PROFILE=$DEVICE_PROFILE" ONLY_ACTIVE_ARCH=YES CODE_SIGN_STYLE=Manual CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= -only-testing:SpaceWorksTests | tee -a build.log - name: Run native UI journeys run: | set -o pipefail @@ -118,7 +125,7 @@ jobs: if [ "$ORIENTATION_DIAGNOSTIC" = true ]; then UI_SELECTION=SpaceWorksUITests/MainFlowTests/testRegularTextDarkLandscapeSignInUsesNativeBrandPanel fi - xcodebuild test-without-building -project SpaceWorks.xcodeproj -scheme SpaceWorks -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -resultBundlePath TestResults.xcresult -skipPackagePluginValidation "SPACEWORKS_TEST_PROFILE=$DEVICE_PROFILE" ONLY_ACTIVE_ARCH=YES CODE_SIGN_STYLE=Manual CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= "-only-testing:$UI_SELECTION" | tee -a build.log + xcodebuild test-without-building -project SpaceWorks.xcodeproj -scheme SpaceWorks -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -disableAutomaticPackageResolution -onlyUsePackageVersionsFromResolvedFile -resultBundlePath TestResults.xcresult -skipPackagePluginValidation "SPACEWORKS_TEST_PROFILE=$DEVICE_PROFILE" ONLY_ACTIVE_ARCH=YES CODE_SIGN_STYLE=Manual CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= "-only-testing:$UI_SELECTION" | tee -a build.log - name: Export UI screenshots if: always() run: | @@ -129,10 +136,12 @@ jobs: if: success() && matrix.device == 'phone' run: | set -o pipefail - xcodebuild build -project SpaceWorks.xcodeproj -scheme SpaceWorks -configuration Release -destination 'generic/platform=iOS' -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -skipPackagePluginValidation CODE_SIGNING_ALLOWED=NO | tee release-build.log + xcodebuild build -project SpaceWorks.xcodeproj -scheme SpaceWorks -configuration Release -destination 'generic/platform=iOS' -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -disableAutomaticPackageResolution -onlyUsePackageVersionsFromResolvedFile -skipPackagePluginValidation CODE_SIGNING_ALLOWED=NO | tee release-build.log - name: Audit unsigned Release bundle resources if: success() && matrix.device == 'phone' - run: python3 scripts/audit_bundle.py DerivedData/Build/Products/Release-iphoneos/SpaceWorks.app + run: | + python3 scripts/audit_bundle.py DerivedData/Build/Products/Release-iphoneos/SpaceWorks.app + python3 scripts/dependency_lock.py check - name: Collect read-only native orientation diagnostics if: always() run: | @@ -141,7 +150,7 @@ jobs: fi - name: Archive validation evidence if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ${{ env.ORIENTATION_DIAGNOSTIC == 'true' && 'ios-orientation-diagnostic' || 'ios-validation' }}-${{ matrix.device }} path: | @@ -153,19 +162,22 @@ jobs: validation-device.json validation-bundle.json validation-debug-bundle.json + dependency-lock-validation.json + build-tools.json + Dependencies/Package.resolved orientation-system.log DerivedData/Build/Intermediates.noindex/BuildToolPluginIntermediates/**/GeneratedSources/*.swift SpaceWorks.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved retention-days: 7 - name: Save Swift package sources after validation if: always() && !cancelled() && steps.package-cache.outputs.cache-primary-key != '' && steps.package-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: SourcePackages key: ${{ steps.package-cache.outputs.cache-primary-key }} - name: Save compiler cache after validation if: always() && !cancelled() && steps.compiled-cache.outputs.cache-primary-key != '' && steps.compiled-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | DerivedData/Build diff --git a/.gitignore b/.gitignore index 4297147..9e83129 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,13 @@ __pycache__/ /build.log /SourcePackages/ /release-build.log + +# Private deployment inputs; templates remain reviewable. +.env +.env.* +!.env.example +*.pem +*.key +*.pfx +/dependency-lock-validation.json +/build-tools.json diff --git a/AGENTS.md b/AGENTS.md index 004a647..57a6fe2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ The owner merged the A0 foundation and subsequently updated the delivery workflow: - Start ongoing work from `dev`, commit there, and push to `origin/dev`. -- Do not open pull requests or create feature branches for this batch; consolidate on `dev`. After the required repository native/CI, unsigned Release and actual visual gates pass, merge the tested `dev` directly into `main`, as authorized by the owner. +- Consolidate on `dev` without new feature branches or pull requests. The owner's latest instruction uses the already-open iOS [pull request #4](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/pull/4): after the required repository native/CI, unsigned Release and actual visual gates pass, merge that exact reviewed head into `main` through the existing pull request. Keep `dev` and preserve phase commits. - Retire obsolete work branches only after their work is safely retained. Preserve the owner's configured commit identity and the required Codex co-author trailer. ## Complete-build execution addendum (owner, 2026-10-16; delivery updated 2026-10-18) @@ -31,8 +31,9 @@ The owner requested the remaining phases as one batch, with Android and iOS agen Implement each phase sequentially on `dev`, with phase commits, tests, CI evidence, and a changelog parity checklist. A green phase may proceed without an intermediate merge. Consolidate the batch on `dev`, complete the required repository validation and actual visual review, then merge the tested -work directly into `main` under the owner's latest authorization. Follow the branch delivery policy -above; do not open pull requests or create feature branches for this batch. Preserve `docs/PRD.md` +work into `main` through the already-open iOS pull request #4 under the owner's latest instruction. +Keep `dev` and phase commits; do not create another pull request or feature branch for this batch. +Follow the branch delivery policy above. Preserve `docs/PRD.md` byte-for-byte. Phone and tablet layouts, portrait/landscape, light/dark and largest Dynamic Type are now in scope. Use generated contract operations only. Record missing backend contracts as `backend-request` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7ea9527 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# Contributing + +Read [AGENTS.md](AGENTS.md), the immutable [PRD](docs/PRD.md), the current phase notes and +[release handoff](docs/RELEASE-READINESS.md) before changing behavior. Follow the owner's current +branch/PR instructions; do not create another delivery PR for the current batch. + +Use the pinned Design submodule and supplied OpenAPI contract. Server calls and payload models +belong to the generated client. Report missing contracts rather than inventing endpoints, successful +payments, deletion or access. Keep credentials and personal data out of code, fixtures and logs. +Use private reporting for security issues; see [SECURITY.md](SECURITY.md). + +On a Mac with the reviewed Xcode/generator versions: + +```sh +git submodule update --init --recursive +python3 scripts/check_build_tools.py +python3 scripts/generate.py +python3 -m unittest discover -s scripts/tests -v +python3 scripts/lint.py +xcodegen generate +python3 scripts/dependency_lock.py stage +``` + +Use the exact native commands and simulator signing configuration in the release guide. Retain the +reviewed lock and no-automatic-resolution flags documented in [Dependencies](Dependencies/README.md). +Generated Swift, assets, entitlements and Xcode projects are not source edits. A dependency/toolchain +change requires explicit graph review and fresh native/Release proof; do not update versions implicitly. + +Preserve native navigation, semantic branding, Dynamic Type/Bold Text, VoiceOver, Reduce Motion, +session ownership and cancellation. Test affected behavior without removing required cases or +pretending Windows source checks establish a SwiftUI build. Preserve exact source/run/artifact +provenance and actually inspect required full-OS captures. Fixture/demo results never establish +backend delivery or signed real-device behavior. Use small phase commits with the configured +owner identity and the required Codex trailer for Codex-authored changes. diff --git a/Dependencies/Package.resolved b/Dependencies/Package.resolved new file mode 100644 index 0000000..fb72e01 --- /dev/null +++ b/Dependencies/Package.resolved @@ -0,0 +1,186 @@ +{ + "originHash" : "dcc5448c9179b753d0a49c88c9f13480941a1c63095ef9668ef2bdf674ed31e2", + "pins" : [ + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "97f7d74dd0e8f3d0fe5fc75cc8957aa73bdc948a", + "version" : "11.3.2" + } + }, + { + "identity" : "appauth-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/openid/AppAuth-iOS.git", + "state" : { + "revision" : "a972daac82d449d58ab119e91c68153e29ddac33", + "version" : "3.0.0" + } + }, + { + "identity" : "googlesignin-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleSignIn-iOS.git", + "state" : { + "revision" : "7823e518f5d4db765ccac3e140dd3265ef0a1cf1", + "version" : "10.0.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "5459ee5603def3db0fe14ca522185f42c48d978e", + "version" : "8.1.5" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc", + "version" : "5.3.1" + } + }, + { + "identity" : "gtmappauth", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GTMAppAuth.git", + "state" : { + "revision" : "57094cd46bc8700d3a887e2a429f42b6e8ea3f0b", + "version" : "6.0.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "openapikit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattpolzin/OpenAPIKit", + "state" : { + "revision" : "343b2c1793058fcc53c1bd7e2907f8e3a4d640fb", + "version" : "3.9.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", + "version" : "2.4.1" + } + }, + { + "identity" : "razorpay-pod", + "kind" : "remoteSourceControl", + "location" : "https://github.com/razorpay/razorpay-pod.git", + "state" : { + "revision" : "a7ee6d9db75d629376a2ea0d5c19cfedf8bed95d", + "version" : "1.5.8" + } + }, + { + "identity" : "stripe-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/stripe/stripe-ios.git", + "state" : { + "revision" : "677c04d2f5fac600779a52d3f43de8d8ac8dbf1d", + "version" : "26.14.0" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "35a1edcb8ebd2bfe251d587f0b69e62af28f508c", + "version" : "1.7.3" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types", + "state" : { + "revision" : "ef18d829e8b92d731ad27bb81583edd2094d1ce3", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-openapi-generator", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-generator", + "state" : { + "revision" : "f876c5af4beef7061f67cc2963232cbe6c7e5310", + "version" : "1.10.0" + } + }, + { + "identity" : "swift-openapi-runtime", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-runtime", + "state" : { + "revision" : "8f33cc5dfe81169fb167da73584b9c72c3e8bc23", + "version" : "1.8.2" + } + }, + { + "identity" : "swift-openapi-urlsession", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-urlsession", + "state" : { + "revision" : "6fac6f7c428d5feea2639b5f5c8b06ddfb79434b", + "version" : "1.1.0" + } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams", + "state" : { + "revision" : "a27b21e0c81c5bf42049b897a62aaf387e80f279", + "version" : "6.2.2" + } + } + ], + "version" : 3 +} diff --git a/Dependencies/README.md b/Dependencies/README.md new file mode 100644 index 0000000..ecf7412 --- /dev/null +++ b/Dependencies/README.md @@ -0,0 +1,33 @@ +# Reviewed build dependencies + +`Package.resolved` is the unmodified Xcode lock exported from actual source +`47e1880a5c7fe549608e2a38cba789d47cf152dc`, focused run +[39100123222](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/39100123222), +artifact 12125599794. SHA-256: +`4a0c17f8dbe46c87952310b079a07b89481d715af79b17a2d164581e7b7b2aa0`. +Its 20 version/revision pins include the direct and transitive app/code-generator dependencies. +The package/project declarations were unchanged at `775f298`. This provenance is an actual focused +build, not a claim that the old source passed complete final validation. + +Following [Apple's CI guidance](https://developer.apple.com/documentation/xcode/building-swift-packages-or-apps-that-use-them-in-continuous-integration-workflows), +after generating the Xcode project run `python3 scripts/dependency_lock.py stage`. +Xcode builds use `-disableAutomaticPackageResolution -onlyUsePackageVersionsFromResolvedFile`. +`python3 scripts/dependency_lock.py check` rejects any byte change to the resulting workspace lock. +CI caches include the canonical lock's hash and archive both the observed lock and verification. +The generated project remains ignored; do not commit a generated Xcode project to retain the lock. + +For an intentional dependency update, change the declared exact versions, resolve explicitly on +the pinned Xcode installation in a reviewed development checkout, inspect every changed version, +revision and upstream repository, then replace this canonical lock. Run the complete source/native, +device-profile and unsigned Release/resource gates. Never accept an automatically rewritten lock, +silently update a package, or treat an SDK compile as live provider/backend verification. + +`build-tools.json` records XcodeGen 2.46.0 and librsvg 2.62.3 from the actual published installation +log. CI checks these versions after installation and records actual executable version output; +it fails closed if a future Homebrew install selects a different version. This does not promise +that Homebrew will retain an older formula. Review a deliberate toolchain update and its generated +assets instead of upgrading the expected version merely to make a check pass. Xcode 26.2 remains +the separately pinned Apple toolchain. + +Dependencies and supplied fonts/assets retain their own applicable licenses. This file grants no +additional license or embedding rights and does not choose an application repository license. diff --git a/Dependencies/build-tools.json b/Dependencies/build-tools.json new file mode 100644 index 0000000..2de630b --- /dev/null +++ b/Dependencies/build-tools.json @@ -0,0 +1,12 @@ +{ + "versions": { + "xcodegen": "2.46.0", + "librsvg": "2.62.3" + }, + "provenance": { + "source_sha": "47e1880a5c7fe549608e2a38cba789d47cf152dc", + "run": 39100123222, + "job": 117594513500, + "evidence": "Published Install project and icon generators job log; Cellar version paths. Same versions also appear in the 775f298 full-phone prefix." + } +} diff --git a/README.md b/README.md index a736794..68affba 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,86 @@ +

+ Space Works box logo on its supplied cream background +

+ # Space Works for iOS -iOS 17+ native SwiftUI app for iPhone and iPad. [The PRD](docs/PRD.md) defines the flows; the owner's extensions in [AGENTS.md](AGENTS.md) add tablets, shared onboarding/auth UX and completion of the remaining phases on `dev`. [CHANGELOG.md](docs/CHANGELOG.md) records actual phase implementation, tests and parity. Only recorded passing phases count as complete. +A native SwiftUI app for makerspace members and staff, built for iPhone and iPad. Find equipment, review requests, manage bookings and keep track of your makerspace in one place. + +[Android app](https://github.com/SpaceWorks-HQ/SpaceWorks-Android) · [Product requirements](docs/PRD.md) · [Phase history](docs/CHANGELOG.md) · [Release guide](docs/RELEASE-READINESS.md) + +## Screenshots + +Original, unmodified Simulator captures from the successful iPhone and large-iPad jobs at source `775f298`. This gallery documents those UI states; it is separate from final whole-matrix acceptance. The gallery includes explicitly labelled Debug sample flows; screenshots do not establish a live backend account or payment. [Capture provenance](docs/images/provenance.json) records the exact source commit, workflow run, artifact and image hash. + +| Welcome | Equipment | Request review | +| --- | --- | --- | +| Space Works introduction on iPhone | Equipment detail in the iPhone sample makerspace | Request review with readable native controls | -## Development workflow +| iPad in dark appearance | Largest text on iPad | +| --- | --- | +| Dark landscape iPad sign-in with the Space Works brand panel | Saved sample member profile at largest Dynamic Type in the adaptive iPad layout | -Use `dev` for ongoing changes and push commits to `origin/dev`. After implementation, required native/CI tests, unsigned Release validation and actual visual review pass, merge the tested `dev` directly into `main`, as authorized by the owner. Do not open pull requests or create feature branches for this batch; consolidate on `dev`. The A0 foundation is already merged into `main`. +## What is included -## Build and test +| Area | App flows | +| --- | --- | +| Getting started | Optional introduction and replay, server selection, password sign-in, MFA and account recovery | +| Member workspace | Makerspace switching, inventory and search, loans, requests, spaces, bookings, events, membership card and inbox | +| Member actions | Request review and submission, loan renewal, booking, event registration, tickets and profile/preferences | +| Account | Sessions, phone/email verification, membership actions, export and deletion confirmation | +| Staff | Permission-aware request review, handover, check-in, scanning and evidence entry | +| Platform integration | Keychain storage, native document/share presentation, notification routing and payment-provider integration seams | -Use Xcode 26.2 with the iOS 26 SDKs, matching CI's pinned toolchain. Select that installation -through `xcode-select` or `DEVELOPER_DIR`; Xcode 16.4 cannot compile the pinned Stripe SDK. +Native navigation, forms and system bars adapt to phone and tablet layouts. Space Works semantic colors, supplied static artwork and Gilroy typography support light/dark appearance, Dynamic Type and Bold Text. Motion respects Reduce Motion; feedback follows native platform settings. + +**Backend integration is deferred.** Debug includes an explicitly labelled, isolated in-memory demo for reviewing the flows. Release omits demo entry points. Unsupported contracts remain unavailable; see [backend dependencies](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/issues/3). Live attestation, providers, push delivery, checkout and account deletion require deployment configuration and separate verification. + +## Build locally + +Use a Mac with **Xcode 26.2**, matching CI, and the **iOS 26 SDKs**. The app targets **iOS 17+**. Select the matching Xcode with `xcode-select` or `DEVELOPER_DIR`; Xcode 16.4 cannot compile the pinned Stripe SDK. ```sh -git submodule update --init --recursive +git clone --recurse-submodules https://github.com/SpaceWorks-HQ/SpaceWorks-iOS.git +cd SpaceWorks-iOS +git switch dev brew install xcodegen librsvg +python3 scripts/check_build_tools.py python3 scripts/generate.py python3 -m unittest discover -s scripts/tests -v -xcodegen generate python3 scripts/lint.py +xcodegen generate +python3 scripts/dependency_lock.py stage +open SpaceWorks.xcodeproj ``` -Follow [RELEASE-READINESS.md](docs/RELEASE-READINESS.md) for simulator selection and the exact test command, including the ad-hoc simulator Keychain signing identity. CI tests representative phone/tablet profiles, retains screenshots and compiles an unsigned physical-device Release build. Manually dispatch the workflow for the complete configured device matrix. Inspect recorded hardware/runtime names; the matrix does not represent every Apple device. Windows cannot compile SwiftUI; passing Python checks alone does not establish an iOS build or test pass. +Select the `SpaceWorks` scheme and an iPhone or iPad Simulator. Follow the [release guide](docs/RELEASE-READINESS.md#reproduce-the-app) for the exact native test command and simulator Keychain signing setup. Windows source checks do not establish an iOS build or test pass. + +`project.yml` is the project source. The build plugin generates API client/models from a tested projection of `api/openapi.json`; generated Swift and Xcode projects are not committed. The authoritative contract stays byte-identical. [Contract provenance](api/provenance.json) records its source and hash. + +## Validation + +Delivery requires the exact source to pass **302 native state/API tests and 37 UI journeys on each of six configured profiles**, the unsigned physical-device Release/resource audit and actual full-OS visual review. The source gate also runs **45 Python checks**, catalog consistency and lint. Current results and downloadable evidence are available in [GitHub Actions](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions) and the existing delivery PR; pending or failed runs do not establish acceptance. + +The [iOS workflow](.github/workflows/ios.yml) runs phone/tablet checks on each push. Its normal manual dispatch runs all six configured profiles; the separate one-case orientation diagnostic is not a complete validation run. Actual hardware/runtime metadata is retained with the artifacts. + +Simulator tests and screenshots do not establish physical haptic feel, real camera behavior, live backend/provider operation or store readiness. An unsigned physical-device Release build proves compilation and resources, not signing or installation. + +## Configuration and repository guide + +Choose a server on launch, or lock one HTTPS origin in `branding/brand.json`. Debug-only local Docker origins are documented in [local validation](docs/LOCAL-DOCKER-VALIDATION.md). [White-label setup](docs/WHITE-LABEL.md) covers bundle identifiers, branding and deployment settings. Keep credentials, signing material and provider secrets outside the repository. -`project.yml` is the project source. The SPM build plugin generates client/models from a tested build projection of `api/openapi.json`; generated Swift and Xcode projects are never committed. The authoritative schema stays byte-identical. `scripts/regenerate-api.sh` builds the API package independently. Operation filters expand with implemented phases; never hand-write API payload models. [Contract provenance](api/provenance.json) records the supplied schema hash and source revision. +[Contributing](CONTRIBUTING.md) · [Security reporting](SECURITY.md) · [Reviewed dependency lock and tools](Dependencies/README.md) -Choose a server on launch or lock one HTTPS origin in `branding/brand.json`. Debug builds allow documented local Docker origins and an explicitly labelled, isolated in-memory demo. Demo authentication, notifications and checkout previews do not establish a live session, push delivery or payment. Release builds omit demo entry points. [LOCAL-DOCKER-VALIDATION.md](docs/LOCAL-DOCKER-VALIDATION.md) separates local API evidence from fixtures. +| Path | Purpose | +| --- | --- | +| `SpaceWorks/` | SwiftUI screens, state and native adapters | +| `SpaceWorksTests/`, `SpaceWorksUITests/` | Native state/API tests and UI journeys | +| `api/` | Pinned OpenAPI contract and generation inputs | +| `branding/`, `tokens/` | App configuration and mobile motion settings | +| `design/` | Pinned, read-only Design submodule with supplied brand masters | +| `scripts/` | Source/resource generation, validation and device selection | +| `docs/` | Requirements, UX specification, phase history and release handoff | -The backend and owner provisioning are deferred. Unsupported native contracts remain unavailable; see [backend dependencies](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/issues/3). Real attestation, providers, push, checkout, account deletion and store distribution require the deployment inputs and verification in the release guide. +Work is committed and pushed to `dev`. After native tests, unsigned Release checks and actual visual review pass, the tested work is merged into `main` through the owner's [iOS PR #4](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/pull/4). Ongoing work stays on `dev`. Backend and Design repositories are not modified by mobile development. -Brand configuration and signing: [WHITE-LABEL.md](docs/WHITE-LABEL.md). Space Works uses the pinned Design semantic colors, static logo masters, Gilroy content typography with Dynamic Type/Bold Text, native platform navigation and one licensed Unbounded accent phrase. Owner-supplied font provenance and applicable licensing are recorded with the bundled faces. Never commit signing material, provider secrets or test credentials. +The supplied fonts and artwork retain their recorded provenance and applicable licensing. See [font provenance](SpaceWorks/Resources/Fonts/README.md) before redistributing brand assets. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..74a8c09 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security reporting + +Report a suspected vulnerability privately through this repository's +[GitHub security advisory form](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/security/advisories/new). +Private vulnerability reporting must be enabled by the repository owner before this route is +advertised as available. If GitHub does not offer that route, use an existing private channel to +the repository owner rather than posting an exploit or credentials in public issues. + +Include the affected commit/build, device/OS, reproduction steps and security impact. Use synthetic +accounts and redact access/refresh tokens, device grants, passwords, evidence photos, payment +information and personal data. Do not upload production credentials or an unredacted diagnostic +archive. Public issues are appropriate for ordinary bugs that do not expose a vulnerability or data. + +This repository is under development. There is no response-time or supported-version guarantee +stated here. A simulator/fixture pass or unsigned Release compile is not a signed production release +or an assurance that a deployment's backend/provider configuration is secure. + +If a secret is exposed, revoke or rotate it at its authority and remove the exposed public material; +deleting a file from the latest commit alone does not remove Git history. Keep signing and provider +credentials in the owner's private configuration. Follow the deployment, privacy and real-device +gates in [RELEASE-READINESS.md](docs/RELEASE-READINESS.md). diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b27c211..1a0be2f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,11 @@ # Phase notes +## C1 Reviewed build inputs and repository handoff — candidate checkpoint (2026-10-18) + +The owner requested production-level repositories. This candidate retains the actual 20-pin Xcode resolution from source `47e1880`, run `39100123222`, artifact `12125599794`; package declarations were unchanged at `775f298`. It introduces no package upgrade or new SDK. The generated workspace receives that exact reviewed lock, builds forbid automatic dependency changes, verification rejects rewritten bytes, and cache identity includes the lock. Existing GitHub action versions are pinned to their verified upstream commits already shown in that actual job, with checkout credential persistence disabled. XcodeGen 2.46.0 and librsvg 2.62.3 are recorded and checked against published installation evidence rather than silently accepting future generator upgrades. + +Security/contribution/dependency guidance and narrow private-file ignores document the real controls and owner-configured distribution gates. Private vulnerability reporting and other repository settings require separate owner review; no mailbox, SLA, application license, signing authority, provider setup or production/backend success is invented. The stale mobile typography sentence now follows the owner's supplied-family instruction while preserving the separate license-rights requirement. Three focused Python cases cover byte-exact staging, transitive revision drift and refusing malformed input before workspace mutation. Intended source totals become 45 Python with native 302/UI 37 unchanged. Local checks remain separate from required actual new-source native/Release and visual proof. This hardening does not fix or explain the separate large-phone orientation failure. + ## B6 Durable read-only orientation evidence — diagnostic checkpoint (2026-10-18) The actual `739af77` tablet-landscape run passed all 302 native cases but failed all 37 UI journeys at their unchanged ten-second orientation guards. Full-OS recordings remained portrait despite the physical device orientation request; actual Debug metadata declares both device families, all required orientations and single-scene ownership. No application orientation cause is established. The diagnostic logger previously used Info while collection omitted `--info`; its empty collected output cannot establish that the probe failed to attach. The double-gated Debug probe now records at Notice, retains the narrow subsystem/category collection with Info included, and reads the application's supported mask and iOS 26 effective scene geometry without requesting geometry or changing window policy. diff --git a/docs/IMPLEMENTATION-PLAN.md b/docs/IMPLEMENTATION-PLAN.md index fb31885..7fb7844 100644 --- a/docs/IMPLEMENTATION-PLAN.md +++ b/docs/IMPLEMENTATION-PLAN.md @@ -4,8 +4,9 @@ The owner extended the PRD scope on 2026-10-16 to include tablets and requested phases in one batch on `dev`. The original PRD remains unchanged. Each phase has generated-client API coverage, state tests, main-flow UI tests, green macOS CI, commits and a parity note before the next independent phase. Under the owner's latest delivery instruction, consolidate work on `dev` -and merge the tested branch directly into `main` after required repository validation and actual -visual review pass. No final pull request is required. +and merge the exact reviewed head through the already-open iOS [pull request #4](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/pull/4) +only after required repository validation and actual visual review pass. Keep `dev` and preserve +phase commits; do not create another pull request or feature branch for this batch. | Phase | Deliverable | Gate | |---|---|---| diff --git a/docs/RELEASE-READINESS.md b/docs/RELEASE-READINESS.md index 46aa6d6..11c756e 100644 --- a/docs/RELEASE-READINESS.md +++ b/docs/RELEASE-READINESS.md @@ -5,8 +5,10 @@ store submission. Phase implementation and passing checks are recorded in `docs/ The owner requested phones and tablets and deferred backend setup; that instruction supersedes the original PRD's tablet exclusion and intermediate merge requirement. Under the owner's latest delivery instruction, work stays on `dev` until required repository native/CI tests, unsigned Release -validation and actual visual review pass, then the tested branch merges directly into `main`. No -final pull request is required; deployment and store-provisioning gates below remain separate. +validation and actual visual review pass, then the exact reviewed head merges into `main` through +the already-open iOS [pull request #4](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/pull/4). Keep +`dev` and phase commits; do not create another pull request or feature branch for this batch. +Deployment and store-provisioning gates below remain separate. ## Reproduce the app @@ -18,18 +20,22 @@ On a Mac: ```sh brew install xcodegen librsvg +python3 scripts/check_build_tools.py python3 scripts/generate.py python3 -m unittest discover -s scripts/tests -v python3 scripts/lint.py xcodegen generate +python3 scripts/dependency_lock.py stage export GITHUB_ENV="$(mktemp)" python3 scripts/device-matrix.py phone SIMULATOR_ID="$(python3 -c 'import json; print(json.load(open("validation-device.json"))["udid"])')" xcodebuild test -project SpaceWorks.xcodeproj -scheme SpaceWorks \ -destination "platform=iOS Simulator,id=$SIMULATOR_ID" \ -derivedDataPath DerivedData -resultBundlePath TestResults.xcresult \ + -disableAutomaticPackageResolution -onlyUsePackageVersionsFromResolvedFile \ -skipPackagePluginValidation ONLY_ACTIVE_ARCH=YES CODE_SIGN_STYLE=Manual \ CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= +python3 scripts/dependency_lock.py check ``` `device-matrix.py` writes to `GITHUB_ENV` in CI. The temporary-file assignment above supports a @@ -142,7 +148,7 @@ Record distribution evidence here after owner provisioning; no credentials belon | Evidence | Value | |---|---| | Accepted app / Design commits | Record the exact tested application SHA, pinned Design input and matching accepted CI artifacts in the final delivery ledger. If a later documentation-only commit follows validation, disclose it separately and verify that every application, resource, test and build input is unchanged; do not relabel the native evidence. | -| Full device-matrix CI run | The final delivery ledger must link the accepted exact-source six-profile run, canonical `dev` push validation and actually reviewed artifacts; a pending or failed run does not establish acceptance. Merge to `main` only after the required repository gates pass. | +| Full device-matrix CI run | The final delivery ledger must link the accepted exact-source six-profile run, canonical `dev` push validation and actually reviewed artifacts; a pending or failed run does not establish acceptance. Merge the exact reviewed head through existing iOS pull request #4 only after the required repository gates pass; keep `dev` and phase commits. | | Signed archive / version / build | Owner signing environment required | | Real-device provider test record | Owner backend/configuration required | | Whole-account deletion verification | Updated owner contract required | diff --git a/docs/STORE-LISTING.md b/docs/STORE-LISTING.md index 37a650e..e19ad3c 100644 --- a/docs/STORE-LISTING.md +++ b/docs/STORE-LISTING.md @@ -69,7 +69,9 @@ Retain original dimensions and current console device slots; never stretch a pho a tablet image. Keep recovery codes, verification codes and QR payloads out of store assets; do not weaken secure-window protections to capture them. Optional staff images must show a verified authorized staff deployment. The final repository delivery ledger links the accepted -layout-proof artifacts and exact tested application SHA. +layout-proof artifacts, exact tested application SHA and guarded merge of the already-open iOS +[pull request #4](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/pull/4). Keep `dev`; do not create +another pull request or feature branch for this batch. ## Reviewer access and review notes diff --git a/docs/images/inventory.png b/docs/images/inventory.png new file mode 100644 index 0000000000000000000000000000000000000000..1aa0d11ab8191b16b634e762f5094ac9939aeeef GIT binary patch literal 354968 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Ǣ-}=83#vm+t{ichSIii19|NooL$+9S*P>XL0X0DLe`tL2KHWNLy9Z%@k+SXCpq?&-P#MB=cpcl-lj=F0>g}JjodhUl6PzIzBk&JmKFRRwLZCPix zOqZeQ-4pUJzKfqW_<4BTN7?UOM^sc*O%Y)kYhA;cp<~TFu6qr;RLvR?EbG&3%$H7t zr;;M3nb@l1B!){I%tm1cL4WoKnUE>|2gj9l8i7EKmep@fV< zZ(I`n)BK&CJS}p5D~3W1j+0uP+WP2vC*yetD;d8|=>S=wi(#noc~@rX-Ee{vV|!0| zlzY)sc_z?kwHKRLsIKwcuTQYg&;@@&eJhAaJvDo_;K}XkeSGqieE{@Gs!eHygCw*9X}! z+phwMyo_tpuL*=ET&!4mc-~1({|S>_sm8^__r`%Ki^1edJ8TqPir9Ksf6kQ13QkSH z_{`tmOXVrEB8#m|;O@SJXiDd|R$mMrvM*=9>!Owu6mLJsa+IQ2cWzx5;O733chymL zxFLO;k{*W`%o4xI03)s`h?b7w_#HTbua16#X<99-_} zh=!fq!a=s{9l|X;TpllP>~MC%ixxL27(ej|@(Vx`f3F$)c!%mdaFvfxF#bg-xr<*= zkr?{K@o~`7WJxzy@Bzq{196w|snX1=Zb>;oNrmrN3j^6HDZkOJ$X8?c;S#tr1jK90(G1r_1Td>I#3oJf z8QJ%X&4SOm`Sj{k_yz|VAT7_?o;}cYYW1AYpZiAtcsBl&I~|i8Q5MXvFDIqRhp8LR z)`hLjgum*I8_&P6_pR__Iiw>Jb^JVd6t2O57xph;qAK9Jcn^jwJ&i~o=L1a zuxP6--2JWN;@1`LFsM3=OZZvWC>8Ign%QJggaf>4=a}8x=k1!hIj1h5WQwL)WUdUI z#!+$=#7%v|@<{ir0%G-U=s#E6O+}Qb%$h!gyq^$5_Ep3$$N&X7y1WdMtdFGZyu9?7 zhTmbWi}gOfw?wYrXo&w^QrXs@Z|{+L#+2`uFFyIyynRUv`;IYO0(fUo7pFq-XTi8W zqpnx3lzox&*Idi9hmur;-n2m>lgk|`{j&ShpXu-zhm_slKT~#2>peG9;O?-J_KF|% zsP!2KQk=R&-^gv6riS^*3DdY8EFQwxAo&7ik;Twi`{uBZ4B08o;g~5RZhX)M3g0H# zx8)3rfTs>I16lYgrb-1wmX1nRHa_N2V;8vh&Eum`7!W~ZFk#h4aU!To9g97!h>g|y zrAYx<8Rs9XJ-xV4`;Ws!%uP z?%Hm2dLWB)iHB%7V^Fn^1*`CaZ!Ob3!#x5m#Me2SB2Cw8vKSVY%!8kqGzWn75RU!mGudL?QA@l z={G7S(SmFRn5;*RBc`ZoIKe!HTZ+UzVW~t?5&))-TTqczf@U zbAo+6UA&^@rFW?C&bpN?b?48q74zl*l=2L)UO<6H_6xH7?x`n?2Nq4U{M|MO!NM~f zv*GRa>m1by^ba(>zy#&piE&cO7R^fRV-4%n{h^d8=|%t`pM!(n@#3Z2qsW9ue@2Yo zZ>g}8P*CN|%K&olRU-OYRhNj3m~@Fv(QzNlQ(qYkWwrfycT(u^pzxWDH>?dxbF-m0 zGd&%|F+IKi61@2C?is=amm)Tum?QRgVS5K8)c0n_w&RYp5ZJ%*=IVSSAd<%Wz6`13 zh9;kon{_HNwmL1N!g&$$46a&y;W>?-z!kb@nGTm)bcAPb!}JayaEQ*En!3*mnDV{H zIr0IO!lR!bCUUM(&Q<`yXDcIs>Xa&S()pA`TB||u8YT>Royp5H&XA&;ozl1dX+;LD zMchgsv@2qakuATxRetv%8kYMM)*Usi>6uF@hD!!b8+S%k3*`LfE+(*E}Zu_0&em`Po z=%oUiCpZi(@*i4(P%PY`Pr}5Xp6`->dVIL<-g&UMwGvV$X_MA zMvj_T(Bo075>XP0S6mojpDD5SxI#sG5CCdRQCH=Tijy8pJ89S@uqYYC3!qngYDLgn=ec#efus;DnM%_asZ5qTP_Wp9Z&5LUZGPqE(NcN^dtbJEF?vYBCd zRj=H|DJbqEIWr0>hb!f&mY=u^Z6d?&j#X~LPy-1wSU>UD(V2b=67qF5wrbmx_%QwE!d2sJz;-obZ)dhAMZkXt{UhXPLSh&ELfx`;_Mm8)?uSu@Yzc(Dy9Zu->nOO-8$&mER9 zI$g&$CUAvJ*xw$AVRA+@}Mlww^=$6!m*zE zYcT#z#y(p3UzN}(jj%qZTT(DHzwJg7B2jnFkkV-9pb=ze^7El3EJNQ5S!&QK8wjLm z&S@ZjCp(m>KXV}M@rCu=0rOy?6j1~!gn58eyO}ZqKf8ZHT=w-WpQHh=&puxGszzgWu{^d6eow7|f&7fS0|mm6?3Z0}-Xf{T~L615a(@=bpf@gT6O_;BPB2J=3B5kvE?7 z{O%VkbBGSmAE@U}q5m~F#eRl3dBTR)-BWF5FMG-Wj3NvwbJY}rDo*}6>MfIeO1CG4qU^3Oy z$IHYjx${Hw1CC%z@Hw#xA0w2B*T0=EQ&dxDYOYAxQZ%NVB(6Yf7C+9-dM6K zJ+$x_Tl=}pcvemg|p?rcwErz&tb znJvL5EG+6F>s;KLExaOuTV}iBi)e<0><{}o2Ra3Qfi5ixiFCoK>5;y%RqTk1nj*YI zcIvTA012;U@(1$BbWm0WwVt)@Ype%qO8puAgLnKyr3dFsc;S!Z85!-OhQKPobYP@T zL~G#gF8=QBc|=5c1sSf&x|RDU0*Jm`kUS<+^@XA;%$-~e3Vi|#nd{f!9DLEv?f)^0 zV8{iei8LF1D3qb@cp(;*#nL;rZY^8;jL@XB+?0cWud(J{J`+9>O3Cf`r~h} z-_MH$YYc8PGd)c7P;jGtGNfK3o_Z_gBi3n}vGzDcJg(v~%ly`KrSQI$Vka5D zt5yBs@XDT$NjzEEx4{y#L?yzb;gJ2Pc?BH=50&_BBh=?r1zu8Sfgoe;nI+)N4|5KK zMe{L7RqO1P_wmMC;8XPP2*C=KDJudUi2HsfSB?_LT$q-LI2lzJx7N_)c|-1BZ!Afp zYD5c`p=Asz<-XS6?RatF^@{e@W6%0pjk@RRj&hRkq(h|5GT^?*Mw*=8lC%r>XCj5j|2LebdPHO7&v`Jh$+e}3uZGmFHWJj8 z$l@X$hrakTaQL7}?(B<_u=_%yeegz#I+$Ov`&?kel^LK6n-eCuXbAP!St+lToYCbA`xUn$>n8x~};w-4L&h=`FZbHTi}Y7)EqpG)#$-RPM2q{Rrs zhV=DesryxBfjnXgT*;y@7$)qG@H8;&8WgCa_i89coe*tH#JQh~E zRQXJ2^Du=}VG76hKv7xIsQR5fo6^e?(32{hT3Q;u8_H6nvA!XmCWG3FnwL$Nc0`04 z?9&87NQ=1DFH5d5nCZcphLdAWtJ(q{Dku+JGu0l(yVSF?BZUG%`ME!uHFYH zur8Tz)KvxGv;M1{qehY1NXvIjQz1_{9n&nQ@$G}E<`geVtaq*0Veh|NRG8V?#b22# z`{J%2KO+UZ4$$8e#_(a-m*O1w z&yt%JO_u4fzb2~D*EX|I`Y))z@zT7>Bhcu!SjgAKX+Vre3#cQOg@a5#-g0~^t@1}p zA(2?EMFC02}%Ro>k8&oHGfp54s^zzhBexS1T#B3Bh_Gt&Md3VC5fN5Y*waz zdxephfd*h)PriZ+88g~#xn9M3)a9w)gRL~j`?%U3t!Dmszs*P{O=guB@jlOy!^X@H za;cxqSfj{ZfKGlikc^rKnV{BC)TG=gV7P15T9UH42oGn7_c4L9dR>z>QTm{q*0Lvj zj4a+b&(I{3>hYa&{G6r9s76x6l*1-e`=Ie zod1V`EP$T6l{<;Yol(wib1bEg()CDNE$RchR=&ymasJRT__fs(PoUv8J~*%F`j#O| z1{lj`mTh0EYvkKs9-f2YfUA_oP{qi~QihNz!Y7+VRAI!l4`LSO0OUOHc-5GbCnW?= zOc~WD4t2%zWwO7wrzM;uPd*n^fxf2Fp+T3`=N1X~tFhI61vqJsmevoo8m|FW=fS3I zvJ?~%2X9i?kS#n>Dtcpi;xJIaSzH6vm8xTgQx5@1KHf(OrwT0EZ(&)3YeTqAQ~&Evn8w;E}il+0rR!2TsxQ^w;i0vz(y_6t?qg)!MtLW!p1E^(+6iI&`gA zccj_~_zD)QYT{ax z_9J=$>D2M9QP(1X_M$GU{e4jxcZcXBKrZFz25enkbpsE_Qsa>yNv}ltQj9GaWJ_mk zdI-Qw_(Q3$tC~_=I0Y`S9^!_hrVNi>?=4^R@Y8-K{*kVY-7 zeT@9pxQDQ@F|Ok?-IfhmA$0;7j|lWMi=IJY=1kk|{7h7pANJk4p@4L{WjkyB|wx2BR;ZJbMv2wO*TT+;G<%lURjpm-eW1Fyn0q`Z$|^o-Yaon+92dh9 zKCF@t09(!C=GY^RTg6~TiX9p8T38fe@`*e@vUWUL@SOTodPVzW1Agkjhs1132k>Q1RC^zO>KibI3J+zqas;(7LAe!V zoOE7*;$ZBVBloz~Dn3@goSA4OJoq^ zAU8fTeSAzy(t3Hc;zNV{D|~)EqL}h=5?qlXtIFgC?a(=kQ1d0tj9i;q3l5iwrFVO? zln1fMS+6qrFtltiBE=L#brtR4nKp-E{3Sdwd&Mi>EhaJoU@WBjrwbpUd1eCF<^d}N zx!XjafQJ$|rid2%p}-gWmgo+(Y`Up=*J*(0a&&`B(dUk)M1q=`(!`1hkXuHR=?G)y zoNamXr~6sr9z@SXcvP4zEZ~S5N*{FgLo(on_ance&&VfkS5vwxZ+y$q?@15WIGQx# z%yn{AvwPqHDlXXbj3UyB(*8N2Hj$Cxc$&3bEHaOL#Cog-`o1+jznbM`3c+${`axHv zhPa&7UtsNqA$*e3>-jQG4kRw>r84c&@COh+J%%1HZ!+m5OY#m8dcca`G-n43&4ayE zk3bw*kPC+$FQ2|e9tA0SFle^vp{CK(twghwvoUCSG(Zbm?8f4v#`xe6Jyd;~n2HrA z)Ah!#XkGq=6R2{ez^rn6{P$%r0ax$84`W0#jp~5@4}0(Z6xH&5fnE_s5Jj?zfPmyA zS#r)vGDuQ#M#*_Z1td$(Ip++LL2}L+1_{HEM_^z`GxLtu>-}C|)%yotefZ(j6jO8h zbf50sYp>pG?{p8^G3M0Q(~e5bu^tHr*GB&t~ugjl%6#B^7Ej9X=XAoGiz6YG~~M zC>Il=Sbxf%K5@ojmz6}N>!vBal#z0!WwTkUiI)x*|F3Ff+#X}!9g34m=Ul|!O&$Ou zhMekEJ3WY&7MIOpA>z85**t7TTHA} z6=yO|0NIg;dX?8P0|r~0LRJq(o`Jh}anoMMe~)Ya!iXcVdS(n4RUkEHJtA1>4z@KM*df`2RXwy- zs&kI_0Fl~G-{a5B4Mb#Y$>UujN|6bTy7>B|E%4IN+!`F3no4%7%i*NuFIyz}5A$Rm zQ=3GpKfNbOMaQ}OxagS#)S@2GgUncQ?9Kn{#6^qX;q+)Z50@i08&A;7=$vErTtIBc zPVqTLUpf)&w%}}N4pk2uK+0bRzN$IVj(X?($XS0&iAkAnWHRVsp3d)_-V@ZDovgZ3dU>RD+Op$zh|Buv96HCkAp_94wPda|7Srh0J>` znU76{Z3f@kcOi-A;5`jBW3l_{im_NZ9+t?ZH|%1|H4f?Br*BAt09ZcCM0{|GWCyR2D-P4aRv z1s%qcKI!E9Q?O*3-Kw#09A0V>zv@qNb;})M@VF59?GD zWzBsVdKDGOgrD?)y(_QLL;DZP1pe@bpKF9lLP0co;_&?C>x_v8okY|9+$a~k;!m^xcx{3=;Rpk7Q5byo>@ z+O1s9s0uYYt=WFgMeP^1YTbYKk`rUSE57t;+A7KZ_Asu<(0p1E{hQ~~w|wH;Fne~g zl0|r^hVIZc!8=>b9$b%b2xJKZyWiMaSAk}=Hj5rA2Fx{s-?A_1){ zAnkSWw=G~g-Ncbd=j6|NB`(S~a%rAJmI{*f`3~_`$kvx zVtQJ%)U8;N)7!}cAyq7Kg+uo2p?YK0?;-4hr!yxP@Jkhg7u|)Sd1beN)0?7+d-zJS z922+7%K+(g!v`;ap{jU;Joo9zV!8iD5d7tjgI$?{54{z`W#AXDzi&3DG&sg{YNLhd z&}222-U}y+H*ekyA$kqUrRT_w5eHfSLK^e+s2r6m~Qx zU$iPc`v@gboetex_{yCid*{C-lRpK0X8CAU_$0PVBl`qxu;w?Qe_(v4pcIK4UO(2C zv&9=@D$kx{^nMCX47D4h6*;yDFjvxN<3PqKsWHrHPOwO9$5@GsxD@X}0#w4v6FsGb z$a|hWn^nizKD7~Ib)XXF0L##mj^QZ$v3=0KZgBP~aA6{!%1-Pvr%U3}7_czN`ZVYS zVajJwe?%x8iUI$Q^+qXo5LWliDY@sPR`lctnJBvq?Gx*KcfwZ&i6-#f0mgM;eNb_n zFvnF_bCp^RaF-~I*3yJ-?%#OxpVn?XunGX=GC5NQ6HQhY*VIh%dRcbmFD1+y8JT2A zggL!=;t%nQr3_8H^^`ysc`lKexmU;b4Jv*S`$aBAdi|+80v5LK+IRB5$jbd1R3avh zL9x`%DFqAJGJi#9wZy0YRS<0Z{`Bp5mFcInd^V$X2J^p^n%`;cO6eIr)}kyBV4^XB zEOK~{ZVoHfGug~k<#To(>+szxecd#Y)co}kDHlhR*A9L(5QzTh^s$82Q|XGXDJf!M z&A1WU2It`7`j9^}8+?%jlvhX31fV>EWvM3C^YZHvVFqy$)NRxaEqcY&dVw#cm!%@( zgZIzEv5_1Nb~6;dt~K?9@1Ep~Bv}&xy|U7ymJTzja>3C8qL zm1#M3+nYLj+aaunn@+Ma#BSyG<%uzQ>eFruI5OSM3>q)x>Z~TsJx=Wmv#vg3OTK<=_!97GJmQ)(?u44}5PMuP;v{9^SbpW!p;C(d6M0=cChSj)b!U*${A>H1`EOE3CZ#dp_+Qp_)&KG_8 z^!r&nXB}WtT7x&IpA{92NHPwyf9k1*C%1fH$PHquofy!%L-&Fj-3Mcz}hhu9_r1l~w%^(B4QwmNk|xC5EWACrGu#Qz%ToE&{;8f@Fj z5IlXWiJO!{P!De?{A)_NH~hper_PjGxU`_|bpq4QGwb^!!}Ia^fP^tUsz*XX|Am_N z7Lb?)-q2~~-8Ua7ob&7t3~EO3*#_!yOJIkWtaNygsqna8C-*~)+{JQ1u^GW#&?@mT0lDA#iGJ5MEuS;~Fqcs1QS2OKogfs?76!6=-`rQ0{t49GIG z|J#Nb-hWr!v`gD+f^1np z9+G#O{P*Jw>Lo@oWFC@2ai1jqw+$sqjE@2L12TM4B<})wrOteec4DX&vtF^Lznnfb)nyKF#k#~xTKO4;RV1Ln-zGu8GkP76K{{u?W~ z?TB#;FyJ@yG=3;lOsHuYagsh0yn>d*H1(ScG>(sR@a!Mj%g2Uvtk4o|J!}8X)AX1B86m^+=tTVXbs|D@ z@ugKsF-9IS%eT={PXblT6m`PtYRY3G(V{MjNofrTvD(a;e57$f(9QZbbukHtj6vvK zo$vUp@`)zHBXJ+NC3Az-WN7Yb<=O@G2YkXMg<@j=;Sf(nizT^5h>h=joeenBqY~Ty z1ZI)AB@`apP#RT!9J`bP5zza<)Il_9i&cuzG>Q|Ke0%rSa-*V^=Qko#hjOCNVLwf0 zIG?89UFi^(xhV5>K1|v_=B%d5&m3h}nKkA$k3tW+;ik_+3yYiRy0&&$BGWHO=7W8z zkd;sBg?Kg296q~oB&&?nz*5H!AdlsCwccoTA(_< zo<-@;g~#H5eDLHTMkOJpt@laDp|QCJ9eZC{qWXSpT7yoyyuZ2=zqX7DPzxaM%Opt{ zlR`2N;Ec&QsF}^1nl-U0Di+cUPv1|ec*YBkpndsgh!kK%6c3(|-$>&I0$kJ>%OaSb zUt!k7VU*Q7OZlrz)v1JyDU7SRG9>A2YMb#aJTJ7<>bN6AGW=!0=~41o z(6FHu*9c4P-6+e*fDpNNucU#L7gxA0P6wu;ga1WP!m^nk+#(`^N9Rj+w+oZC6=v9^ zB6w)z$-N#D^3#9pBUH{1o~N=-nSGL6gq^Jvl^kJ05Vu2E#Kf9#9EUhR7f5?GT66PH z4N7JVX0bKYs?X(qS=}2S?L#?D#zClbqgLTl!7{#NfgIdWvT})2LAH1@5@x{8&~dBf z$kp`~nq@Gs=BE2Zhxqz%yu&2ck>&sMjfrE#0knag3!UK`S=n24Hl;#{i3jK}Pz1g& zxfZ1O{L)@~@W6I2QN%faAX|x5kf+X5vrRP@M@wy_;lsfOvzraSx99L52M3wVn8kL` z?Z4(P;6(RYh-r*3ZV%`LR=mU@8PX)HhL+~StF13a66W6|o7jsno5~C)(S`EVVE}X4 z4qKKU#|6b~XJT%t_@{)gqn7sNJHfse6Gg5-eA7ohS4awV4lcW#WVM8P`}D_|Tumd1 z(KyA~8PiEUKWjZ|o|YcxXZZXUx6n$lRkG~@qbalMs_*293q4>A_wzo4F#H*+9MfSmVuiTBK{dFb?aqE2Xq+>xwGs$8@}i)rKh)NzH<- z`A?@!>dsx@PDiDg|Ba$cbiiDk8O5g;Xn@)$@Ya5dUAJfafgV`A>@&np_ z4PAiFdM!P6^qeBQjfLg?MrZ!!@0BjLhq*XEIfO~GEK$iV;W$f4l5L`s38P<;9r@HNbNci=lZ9?uSR10_ly*T%U4rWga6l`Qjt!$FO^ec3Ukw^)l4|raLyH3P{hspN}Zq*NHtG?WuA( z1riTox7HIaT)mH6afM}=C{#3zF0l(;k#ZQa7j3d<~{^cfKV*8)@xsR?;r-h}ozY&X0%|AS%Pj}hF z6yCq0D&6=VAbl0p@UqgP0&DX|KG}>#vfcp4XD`4Vh|^?K&$Ycvx?pBm=Zrdq^|#Y8 zmsk2&HU!QziDfR>Q5@wtcC%bhPmNuF&(&zZn4ziZL3Ti&V z>gReN9x9Bp9ActYX+WThI2~%nWlw}}ltTai4fU75e&&P(?|=h&Zc4LH5r3no2o7GQsFoAP9tyv@z}BdCX}cdCjh(=H@0Mij7-Cbkr#PwUq$xiLP*1X(*JZTUYON zTVflMttS3;o-otuiEz!$9ucV8VNOOIP*-|k1Cn-a8*eF^N=U+uC_S4 z&V%`N`t~3c+p4eQ=DVo_0c~BsM!9#QgW#&sS4T^JSFao;*-0bs{`M2Mc`)t?C_@jF z69fdPzr6~m_d8REvI)kNR5Tyit_rB&na`7zwIAXPRcUk#*R$t*B9A7ky6JNmdgbE} zKX#~+16satDl^Ycunk{bkMf64+@li6!GZEA3~)d-TATQ+%LijHtKG5fzW zGdP{U_#%m@vFhzhhp?G<+W>)Jd8)C*=^t*NhL&}Z6PuSQ&ReS?nXOu zaYkG1aw6+Gi0vq zybl=O|V6~8#~(N zhd!y~zEdmpddl7YczlHqR)2El5J#J!yIxC43F-;3oCsoFVfequeP5}L6Ewu-G3#MS zbfT}z^LZO?|5|gCU4WFD;8~>JY~D!%s+m1iBf=DreBDDF-+rDt<=DJY-VAF+qp&D1 z5ukKK?-8C^->X-!mHidwR*Rm|{Tn6!0`c0opESVfw%KEHL;Xhgimz6&`&Hh8>dXb+ z>+aQ>M!G;h%N}?j=*T8Aw3(ig{yr7Tv;<<|`4`z>+x-OLhGP^rc}c{@TpXVf=Rc&W z7NqLLBXIe^Is4yelSGxD&N8{#>nbOSl>+@CxsETC8* zuK#o6ZB+q+WOG`l_<>Sx)1`Bf^7~D@zGu3GvCbZg4#3WlIy@RUrCd}n$`uPyoc zAu|ITI7D)>a>-v&B`0^(JMTCHkFmd;XdSw#Ls@LbZZaW3K2gxDrAn?x82ayk{QS`2 zLiGhm-4azzBJL9eWC^({8Zs;KY2K$bA*|L1*OYeSG@Jm+Z)3mh3N&oNG6S$@Kq85> z=GUFy7HTY6>ox%iV`I0iw!xc`hSoS-XJ2P$;N(_gsc7MZz2zLyhdP=E6tT!L{vnoD zqJz}KnucEeRV6-o)rK#J<Vf4Q97!p7Xcko-gk5BU&m|Mr!9i=Mv8{6vybbsdp zC^+Nb8j0gowuc+dxx696Zvq+ORg*jiDHh*8PwY3XdOD6`4jAl(nm1@XMByBz|<;`7v`v zBPt&Ybv+)VisYh+T8x`(b(?Vox6Qigw%XPoHo0Ig&eE)%~td5ay>%r+5 zR{g3)sFt=7koXjkqxoFGR0SZGc-I#d?Cr-P%$bB0th}o(3FHkcra&z@W4}fT{XCr? zh&dxrkJcO#l}uI`;~W|jG=rY+ygFz>@=8hy`$qG*gIiA6o36hpg504i2w}waU2xZ< zCr=E}9n|@feci2A)1oRz^D)Z| z_b3?b7|5b7d7^L_Cm|u>LL-+&m|ZPO&<*@X z|I+(twxNYl|H&g{PsM1y3a3oV=%TGd%bFL*V1)J7NCpTQj&`4MEm0`jIgm!xh=(3a(&_9$k@? z)o3h)k7!!(ZdUf0D<8OJgsn<>M=K$DGz3 z-nSIJ;K(MOloLhXMOqi2x0d?SI?8+GL4o4JH&5p?P~B9_74T$SJsk;EVdU_{>IK-5 zy=B^7k@HNl<0mrVTd?xgskz{7My=YrfAyO?84c$<-?*pq73G1~I&PWny*4)Xq9o3J zpI*dCY{z|H!SPi=87P5N7;PqW&rlv6|86Z$$KbNLq!2qxamtGU>^GDG{(5qMX}|TV zoV8x}qhdMH#cB+Vi1YSQ-;=b~E{Ji1#kTR2QmIQt_sb&sSEK!mh6(xYTkyzN4PZZd zWL)I%q&b`tJpyhWC>OK`_POSXI8|ZH-w|Q?%>JF(STYG3(acQO#W<89Bs;n3j$dOG zd&{H?dVmpF1_0$kXOLgKiZS(AYw4oPZTUtlANZ7QX~PaX$oIZ7tGH!8o(O!dp&XI^ zQ3RFLZxrDsJ(nMGdD$?x`1V9p_y=@fd6G5)`2J9p@(6Nn6oE7O2wf$5e$)i>S*_z+ zjx}tLnUD^ZX-6HvCf3FkjR9eb@}z5}yF*o1`jv0D_d?B0S#g-ISLDQ9^;taoL4lsYiL&gKg1~>8DF~6%kIgC9){gJRZnrmkzdq`iSMbo-sCD zo(z>#rzlJ8V|H+!@>9w05Lmcl)gc zA|?Rnl&ZQI-Ux)e@DiJrT+yXE+Vk+D%pJT(lW~1*8i~${-UWfKv5V`wu+Dq|n9RiZ z(0H@l8&%tLM(J@K%sIzxwWM#?E@%kFW_4f2162dyzT4;fpuRsFqg}%i)=sriqr-xJ zO>Iou?Zy>{E1$i2N;ZS9g)UKd*)CROQtV|Z*O(2n45)o!wnOBI7n_x1TIUgYHcRbj z$uv-3Jk5i_Y{U;N5trvk;yb?YxndI!L=hhp3!U>p(F`j~W^=GK2Bct*7YrN%#)X^D z7rRM==I*1v>F_PvEiJ)X3W}2pe;dkH`Ja3XR*Z|wXoru-eL}gW)vH7FNpFgy_eohp zkU1@li%IRo*3y;{j7e`L!R@5X-L7sby(~3q%8+|oG9A>i;ZP!k9J=(Uyuvl?v^|QZ z$pane_f25Chq{&?5T~h|UbKk|?{Jy4(mV2iI;OH_Cg25U8Ebdwfgz&b~#J?|X!?Q~WsMP(zIxw(P!PqrI$7{{ldGX!*f5Q!=GJOPP2d=-R^0SodJ_=a2Y+@XePO-1K z>+4;toVm@k;9b9XG3?B4rQxddloFVqKjsK;ufc za}Av?V38Mo0~PQWJvf5f8qV<3dhd{BFM98L)MqH)``K-5`r7ib4#L$tvw{t$8a95-qVus5rZb!kdv1GN^&D&9;bs2D+CTD%sXb>A3prfKOP*GV) z=8J#)WS6L)MADMGlaVT(^Xs;rZT^NxvO*sE6j{I1lq>RM`4Y#XRDziT$0>5Z&ACMn zVJ6?k$n#0QVgV8XOJpy{Y68Qy9|4?$i@&V(4|Wh{=XeF>J`^@-zoCC#pfN{3<>a?h8 zS0eRT+3t0xPd}~^t2A_IqL5zovXOOd3Q_l@5kDaDEsu1&%8606of;wAfldhc5Xj4F z+E3ZGCc{78uy10;X*JymsEoHoz_(#O?aHuu9CsByW#qD&aYf~8GsdP4#knq;o>rEe z_LWtUL)o-dDkOa<>Q?T_o{987j;$55KNA3H8Q7*gjai#U9s;uOHmiO? zwU8Jm`s>YFHy3>PEhd2(K}anZJg0CrAGYDTPv{H3*Fe*LS!RQ9)XJeqR5_CE>tz9j zYIyZcj;Xjn$eUrELG@QPX{_I}(qoJkNJ3*33qa(Fw~e!N&|c&Bz6|06sR?t8UlmXq zu}7(Z^x9nav1tjSeurMPDrHVYjHqcOTzfP;w)*Bb(o9}*Dn2siceJ-}`YGV72>=G= zjlH`SpcR`6Ls#O<&)-GUy^=+0+)v6txvOlU6_TQuvc~gQM|eyq@ST+}&;9U@2HUR> z-dr}8@fs$ZE_qyLI7d?gRbyL^nwiS35AjrXJ5+-VeOJPd1jF{nw-cHb+D%t~T+UUy z?-QzuXdRMY1yo#*srsF-oPXTSjb@8NuIpGP=)Svv7_}}IGgs8_GEL)%oOO%dVRt2! z-W!J%lV5dFqG9s8`uWn#E{3Y%%aMNS--}@#9UWM13-7M%!H9Yu@Ns=ZIjYz^K3_7k zfx|cE$3$OxreNqKyFfSk<_JI3_CcS=nRX@a9BR6MI1JlfBl97ZmXT>|0d^t`eK6*>cw>1K+_&!=zhIV zFIqn!v)4>;8DJvtF-w0}O@CdcH9baE(6{fPei1->o;h0lL4kp@y2ue1ruIo&ZJ)zN zQWU@C{S}`UAkKEm!VffZ56t=#{JV%Cnr5^OEt2wfrW{n!CrJRD)e&eu4XH`9OubmT z4>%>nd2x7dqpAEG%8nk$6!5v1&CNo_Z2g|`R@x0!{TggD*&TAlcBk~RiJX1%t438{ zp$&5H!7&wfc*KhJRhcAgKJrN|Y`O7lL^Bj+=rQ*9!~I(&?B`!OdNW5Y zSVpc`jHTyQ$U3eBQTm3pjZ2FPQY@I(z_~_+5PPz|esOU+lO?B7zKiT*VQ571TzUPV zv}HzHr`Mh?mYgsJex@k8Zl^gyV0{X{u>p7Fj_(yzW*;((G=S7@299}nT=oIFYTzCHb#4!3dcI<;i^O$StH^WPS*2D~JNjh5 zVoU^mB`R>S08O7mHuAbER1AA^qkNI9eq?1N1yQ53 zO(Dvo04cZ<)wodIP;tsD0J$8UFs}CEAI#pfrWEu*dpk4+me-n*4t%Eg3Nu20y~qu} zRC)0$WI0awtkndqI1kkwih?bPav!9{j$O=ws(Dytb~s=8>`~8A2hJf6uk|b1eU{4V z->UNMaKqd2ME8$^4ljEX(E9nB4n)3y(;Uc5y;_x!>Mg_GVyaK&HkNT1xewWP+=j8e za0V8!ld-JrP55D+U!rf|I8H!*9C};YD$DJB1H^JPWc)%XAHcl#@$d|N4tL-a?-TD$ ziH6qhsMK(jNd5+8afI7ag0d;v!cQDCV{?urcZ)tmq3>3w`dOIVn-{#J>bM$z6mI4Vui=1}D zAZOTMxB-20qg8nrCOo3`?D*tvtEsBU+Wfiuz8=)*VNJM-4a%cWIBEz9m+iB!y6~p% zfoFAU_z_)k+$g@-HjR|kJ``V|iR^Xo>pQ@?<{Afhfq0ZNzGur%mqL!#>>vMd{OTu# z&helnNTV#Zx=FIwrsZ~p#HCS-HJ>Q?wlIA{PzCcP6@-4G% zolg`!5Ut1(<^6uzGdfyiJ+Wl^mJ5BNY5RrN3SqT1p6Jy8o`hNj8mLtzy#=`U;AV$@ z`PStT9plS*zE+NBmDiM_o9{C+t|f;SE^7h0LU8q5J=lF;RaL0G*xhcgPrp`Tlh)z# zlEGK6{N=mJzSWG4iFQ#n;GpL7&gzTx_7@Gu3d|&n7gz3Js^ircsOyAw;$mEme^3UT z(?6o#85a|_rpa7TdOwC~v4d!Z!u4g8yYl>7q^s!Z(Z|qPdSuG#c_6zVYKkM+h1zxG zl&_D0hR6779jXIz-oLkQmC=0o)zHkGPVnLrY|^rjT4Q@Ecp4=gB0qar;tY@whSwHy zp5NZBhFli{L0&&@Q06Y3^jp))z*k1ab zJax+jNkwtT_md+)K4up{7b)k>v6>Pr%EPd?!HH3W1e{Dp+oL2dx!(Kr8o>c=h=U4a zH^>YzNY zLa4EiXYTnxLAueI){s<)w`~33VDCzFHJ=P$V;(4fU{+V{a<>T5N%Rk!m@n)f%_^m3 zP*|jRDF9Rz(o%FOx$nCqa;)>9kY~{|$X)>!Bl4pj+~gQ3-wqEcQMh(`-@xhH6M87M zelH*u<_DF@`m(pWceDBHsNLArahq#?fvOS_YJgo>c&Tl7k&MQXsekwS$2@v~?P18I z{c_~>a)HD|HfjeM!K%Pmfx;?_+%a;65=J23) z3Pk8xGuZ#(%RfrLj@oDA5Vr`9w0Pwea~&hXi-?8&m6UPTMw%X@1vWv4y;FUaT?hrb zPb0o&nS0A3P}{soNBr9HW@+ozV{NZPm%O=ikUR@tm6P};U+H|4i*o+5@`>JM7A61l z<12~AAbHYDCmyBo1^4}!sODYwN5~qLYbwpt?7j-!xMS`iGH?U2CaJ%m^wic2` zbF=C9qltm=w}agxsJM~^l7HB`1;0Osz5<7a)2sC!BZ$h9z3-eb=r|CEAFz!HbKt4~?KkW3 zt5vkK`(;E;a4C0g&mD-4kr$({--Y>Q_&Ipd#M|v;0uP3WK_ktx7G1cM6tYBJ+?U<$ zTd0sCgS}*>vSaX@cmca&zcbYU0T^R-&SBMd?70)}aOm~W(hJXsk8LKT7p#J+PT~ok zgKXO&9BsOrty>#+Uk%FSbYP8@E|;rjc#G5nQFc?=?mq7#9fIr|1TF+-q)0o!bg$qQ|NTB%Uj7BP#NTsV#P~miQf5n59XA;08l+c1E|TL-dFP~ zqWO?DN=5j`J-!tQkS?2HS3()NuZd?7Z32rLA>(gb4#V`SP1|!XtwndZRPqd&^lZ2;0mQeDTj)lu^MMHks4T}&@W%{9G=sH?kK;1;M;!HY4i0fr6KV}0bJv2Ld0-fu zLRc7X1F9}N-LKE$38+C5o>m^p*1A5)T#kZrIXAT?pmZ}FR^S(^p!FbhM(fpk=WGwM z7pQ`{4V+dWi874(X(2=QQ!Pv~UQ9K$%D-?Js7b_{sI`(mPkO^z5k@cuEIFv}^F%(g zn#P4^DMNIkqKM9sec;cX*WHt|hD#Lp^o=WRYJ5*81lpVtIH2>4lTKswSbdL#^Ij3& zn1tG|f~}aDVt*+I5WE+l&|y5=ohg|s_w@Pdi6yd~ruh0u-eP#_NP1v`gLDS#+kjn0 z+Y2bV#V1;M_rbV(scM)KgW1NGz1E%xV-5R#3XwqF(FbqajWRq$hjn3LoL^jbLS_s_ zE?K-gCmma^*3x~k;7-vIKkKQ#7kh9A)A$8FxeDDW|04$8=Yj&8e&G>qR^K^4e7}vj zqAiT9jV<=13|uH@m%MZ_GO~KQT4iLE{3s|x*c}Do6G*5~1I;X+TEtSq%?Fcdr|2W% zA*-Bn z6meQ^b{v=q+W0uw<^1>+4YPR&5k>meM3;IV{7jxIDSKmr`X)r{FL2e6P8sXV>cK)| z`a<`v+aVTDXC#~Qp(Cl&8(jzjI&Q?+7w)OtB2a9hu42oNYWdQYQwwbM zWFP?3kBXDlG{;ikDEQtu%oc-kyeg2O-}gwqfuD;mmc?kAVkY07pGrMSlhhVr6 z+noCS-!<#?{a@dONm_1jD;6Z_fzp$B%rM@2B{T5!kD0^f}^afal@kwVmUz zz5fUC+^R#ld@yU;xo)0LKKgt5 zRMz%MXR_THJ+jJg9 zI=+C)A@3VWmg;n~8U|X!^lPa;a;I(X5rVv<`3V$_m#TaTv>WTgMTu1M(a-SLtf^_J zscE`szI8#_bW1&6zqGJD_aMK}Yj|@qEow26)b6nqVQ68nwbJYk;cvP(1ceMHt%Myj z68?Je=)0JnFQ9jJAC3s$fjlu5+&Y=4$FvSvTb_PL!>q-yS1yrUY(wCqX&rJDAa?1p zLj>g)0O(8ngeq!xe;ak%tpm+OvRalI<}l;KBlAkJ=8Uz!w3!ls$~(*N776U*T* zBGl9d`JL^JG$T+)3Iw(QBE=A#w zh0gR4h@m(}>umwd-H>^aBfYKM(2S2A9CL>j_(Lc`+v0?F6fHI*w) z_V?iVKYeg#;_YZxyfdMWb)ZABXc+uy>Q%)i8U=fEi=X8ICa$mupX1u3Tba?1{6)~1 z{`Sb)nQ8b+)mfK>scAR+#&q+RS;xWtqm2?x7RtxG`NnxIS?PtwI(~MR0xsu;pWKdF zd-pv<&nSg9J9lVbWIXr0v?X8kddm{@YxO*^!gwcYPi(~#(RV3Vnuqk%r5uT1N#bJC zc13q~o#hbYQRzRu`w$1(0y5c!HQx2fezP5MI`D{9v%i~>i^!I_h9`oIXa1w^;n~4b zNWq;WJ5ML59m*g^+!z6FaGe3OGPhqGYdo5;nKtWOhsfYjiT*0M?9V9n0&3qpPs33V z{^8<;kwkc7Y$1eyzykY*WDQIIlsMQYVzt)1Icy$|eb>E|toLJ3#MgzruX~Vsc3ubF zF%Ic>S3q{j5Pn)RMesr?&gRe(J$L`04ofHPh)6LCF3>bB0DpAA6(e>2|qM zv)f_IZZ1zxy)-5TXImi_97+7q3UPJ8hQ)Qu>^Xzij@w-g`@^ zW+%Y6^%9#KgZps7nC;VEbKr}sR0$Hw)h74N5}GV4@0B6gRUP6uK+x{g{?25s#lD479 zx3sGo(!}`5@SolNgP2^9b9G{c7ui0z*;u%S)p9VFG2LNW?1&-B<*F}3meIm(n9h)! zjMv^(X=2ImIJobl>*{?Nb{vfx2P4qb+aBdyj<-APh+s$N`J8JVkBVnk=E& zR#RnPcIar5B#;WKUTutp-#ig=VlQyG0vaDz(;iM0)VY6pCmF{o3mQp^Z% z5c=Sscl+{bZ5Ka{ag*w~Zxa@NT9}cB<7q>a3wnD>WY>9i0IO3aO;i&PW2t3csg-jR zE+a-F&Zd=}I6(mHK{=jFI_~?6IqsQ42V99h07TsYW=pj#J-05t7d zO{h-csYHs-bn{c8!uQfJUCCBwU`kFFq|xZ}x(=(reBnc12i~bDKSO%-49>;QtGAk! zK)JaO3VfP5cIU7|5`1>yN!sl0P2Pxd6V$r&UFQEj{N$Pes>KtRXY5$Z_JpYH$4F); zC+Fb1Z#Vnw3oszos8yN!D(ON=8cit)py6bVO3j&{`|j!X<|BfF(WaOJ6+B9${zp32 z;%R#p{InFORJYUX!cYwyc4cCZR>>vm0346EIM(lyWb#~t_?YLcV?`{Za-$ZUi5U^P z8y1f4$oXlpQEjbIaFs=SLWxdUAvku@p3T{hh{;fy?o}-I5&4FIwSgxNVh?sZq39Lt zsKDsX<&J9Vc)H_dtdjG3Y#~CuRG@7kZzq(!87)oU*@!ttY|Eo*e3)mo_TiMWB+uPw z5+5jJj`^T6{(V(ZSE;N0yIEfS*RwFUQ;5ch`h;RX~VV&Ie%=_YCPOuO`wD2r1 zvQ@GGVWw{(Fp|z1s+8w(wtO*VIeJl3F`Cr{``nk9#J!VQY z1-$G+u*w+l6I{|3?uVT;CN~&)d(Q4($Ux;PiP=sjhNhQ7Y8;ze9(W#GcB`&7ElXz? zVN|D|YlQDIvuUrrQ#NwAS_k`LE%o?mdVtX=#3h2;S-%KgHG>F9hRzjn z=SiQmOgA;+l7X9i3$iYUI!rG(Ts|9Zg+PY-=k&e2v+7 z`Y@etBq77Kp&fVq-VVuCJq&u};cx7VdMA{gra|)y?qmc76#@-x?Y1W7G`D!KCrve*K9_o4Y*;_uw_-4}) zKXygg5|-07nc)<}k8Ou(^zJIB1e;}L>HOnPvqgGkV+E8wie=qNZ=griWAcB`x?#dQ zr*Bm#Y(3E{Dq9x<@z?tr;Up*?>GZG8O5Be6ZMP^<8}D@%8+c(vv8B{Mdgxcc2k;OD zl^oB<(%D^FcOZm}#tjq2kUjFURNZrzcOCJVUg6gYj4YL9Y+^sy$V zL)2dU8U_*kr>Wi@3^$)bhZA*6<)|_ed-Xnt;at7xNI2&2g~sIc_vs})G(F93$06<~ zwdCplZs|Yj*e2lgqvHisJiJDm^SNrWZvIvzS3w1*E`S@myc_Ly2=wPzyjmp%9X;~U z*UhL_3#BK7)m5p=6UmGD4ObgMO0h)_>n_fN zPd_@6i5SWHE%j?A#ivhn?=%VdF{TYyKUhYUl`PeHiOWcP8ANRO6FeKbS%T$W>Wg0C zYe8f5RGHQPvYmIhZ>(M);MSHLmx>%?i*4x(e)TQumPRE3_2K&Z3&Ohu=Tt9Ubwe%2 z`pqCRwrqGO{p%K!P#XVKTqu|KeDuU_lri&Ds)g8hG_x!y{d7AeLX!NE@0_$Cu+Z_3 zG4Kd8ZN5`dCy?r^(G+=N6yE?w*_Y_Brq1ldC1_F<#*9+yx!PYf(SlUTjB{V!KyxQ) zjZ$+JMNkNA@2`<_t}3OFW+3(z+4Rf|=s?LLZ{Pu@%@F^Y!u~rY1|p|Yzl@EkKPI9g z{pkGrQ;{FwR_{GNhlQ`^OZluzypAh=m&1)iI^_oKs7PdV9u|w%U@U>iKn?fB@m4(l z)PA!N_zNTuD96}tz7#>;eAYiUVM<@#-MgsDWEplxm5}(+BVuBzNG`21%{G~t!8}D0 zE^kiUNcNzc8$@4v<9Z--_RB1MOQS2^rffzn?>6$E(5CTnKG325bbWCwH~ds|a)Zdy z8a4JhSa4Pp1`%{WWHG9DTJ@KtS4|Ufy&N9UupCVnWKZ+hnUon&o%nXkcCJvnSW3-n zlo&R7g@6sIZ=8^@tu&)M0=5(HlA2_jnwV{@=M7C}s(-`)G2Y{g|d}2$%;t zsl-F{|KJyK8nG9anT!xZ{rwr+V}3~t{`Py{8@&;Saq}hBgR$@x4(=f_TRpD5k(36& zi6BX*8*Wl-qCK4V@MPCNiN`H{HMHV-p-?Icx`{P=szLT^YI6hY{xJc;gTFVR-xuaP zKlW3JUj+;N@@4+E%)sUGjc@$-u(5w3(O>S|`}f3{cc7)?3e)(1`aPwFIueCdA*f0=WEtJne%~y{x!G znm_K43D}aUFJFE?`>*@ziobt5ic=;<{%fuLe!TF%)=~pMVg7o`e}$ZT)X(|2MSE zzzX@_to@%s_@6-loQwb2+P`l6&mi2u{2xL9$dCVDAOyKm`M;6RbGqjq*P8%J?eiQC z4JVV5{?wNgj!VVXaX_@r_g6yXPXzT^?rA5o#1fZWZ0j%57XZF*qv>-pABm>-p2fr_ z!1Sb&y(X@iaK3#=p@l_IyR@QKH7)4AeI86q)^NVesTD%>Xi_FGGGzDx;5nqz83}Y-uGl**vi)PE=VFod{Yr~MV=74cx6Qq*$WTyA(UXvDKb2h{q_ais~lgOF5B zDKchW*j*!e%()l`GO?H7ch~01{U+ zM^d3zp}Tm*WyyE2SeCfpw2)b85To`6^Mlg+RSEIgOoRJBX9zLi4z$-#FB6y8wwyc7 z`Q04rGk~MImk{wZUfXme=-TMlBGf-i^YE0KpGNAAOVeh@an%cT>VMP>05LAi?0@8aFl->jZ>C!t< z2q3+82p|g5J0Vn&66w-H2LD|X?muvTV}$HvW$n4vn)4}h z+b??{qGc+~8sR50bDKBm=W_f7?3X-n+)L<6=W5*b`Wn(DO7R)o(4;mz zTcPvJ-TpqX*f8tavdE0jFqqBnqGqX1-EIGM_oSBwOg0!9HfEPp=QDDiU|o>NEmYQz z*xUF%aaZ#DwW}HRF^d~j(F9}NkM}s|;?B6Cpn!5le@V$(qeW| zLzg;5zsiNYzfSZ>tL!yY8p|C&(ND(f2Ux7r>U<7El~c@`sc*jkemCj2+b2g6n=2@( zqk=ar9C(cls6wZaVSqF}2kNdB0*E^zslf)VQPL!`og!YQ4_sb+MBb6c=UE03u*>h$ z(JOoXLZ=m9I+>0Q;`WQX?zq>eQ~|Gso)-C#$o<-wzkj2@r#(dCoMNzv>j?j=zvr=m zzwwh1sVe~|K>ZU&awykdV|F}y3Tk>fKh^jVv*hKXN>c(E!SH}8*ZEu^G}z0TnQFI) zW>4pWr3w>T4R*I}2|=f>a8J&$)CN*p8JhL*D|ifVj;JSwQBoD3(fB=$C@uo2kHD0P zUd@$y+CM7U$r~nHUWGb-Q`=oNS;3}L_$t*xfsR~gF0jwhyxir+K3g_{>n31SrRrur z0plrNQ}@JwZ}6SO9RP?Ej)^~n$qFOg!dDmo`6s_QpqNkLAZ zdi!&bLAjZC{|o7G?N=r>IZYAo&U)f_ND2*VnlZPstl~k7PWI8vQgI8}XET*Md_q zrY()%2U9U9L&$Ia8nj(_M@T*Q)nzY1T8cn^lS#{Rk@qp3pcj`#Vo&SsWtd~qy*?ob z(WNrm`=*E*DSSKwU||Hzh!5WZD$<+$z>1>eP8M`r*nKj>JjrCSe6Y5en=?M% zYCVc8b?8JX>U0%q9~p&G@Aw|t&WqNBk#``HQ0dC=1rk}DEmS4nyQhLZn&v*fE=0G1( zaKeivcY2fAmZREj0I7s9rSqPlOZd5AwFkQl20}HI+ndCraH-{o|CDyaentZv*}$=3 z(DB{_vr{xxH7F<3i5E!q=h-nDnjK>Hs{#J^fC$!b&Q^gtP5X-% zp>U8N`IVsv8h~gTRh5Y0vQ%$9zw9~ck4RA*vrjGe8EMwCW={MiQrWYsT_9D1SRXC~ zH6iJmw)GAYdJ!!5B-gx+$X6Zys+|dWs&`uvM7?H*&%ZUv z=R6+^4@>L}ujDCbR*tP!$sq)Z?wu@-aB#Nu^9l^s+KQNS>0-uW(TIMqgI*z1m(r^! zt-KRmG$A^aG6#DQ|H>Ui`d(nSm9tcF%vQJBtKocO`GVOFbNnjZ1}33&n6KHD#8c$m zIX6yCBgkue0_>s4?FsjVo(WS#AB}`FQYl7?@${>T3@wdvrxyKZ@>dSH{0&u($wF;a z_`z|#K+|f06Zv7j_Ctv|#e`JZ{f?cXT3h27uw*E~5wvQkpf{zqKefh6ETOd>7D25h zXis}Aled|G%5GA^Ij@~6^O}VzKn-YbDQswRpz_fBfQEbxLI73GAD zm!?y@ZCZ`bE4P!url8U8p2lEiFE_F;dNPUTX@A+T->WTE-xSl3SI_v~cB*{v{o0Mu6Z3a6gbyqvZ;% zbQ5h3o2NwrTN18Q__-NZIDwJd9<<5wd8+!t-S#{d%wYN1gm&clGL4Ao=(xnC6X7uR zNInZ+zh%D+A>LzV$(>Yi3efR)yaOa*eZFo`Wyh&(h;6aIFCvaB6u4IFK4W%sNw~5$!`kzWa6OF` zo_OYGO9cT;A)ehhxy5xHYkzuNg48g9%v4u&rF@XmiSPWnYm4_WTWo(TjAfAV*lp*M zASw;$1nX`&`1Iry0lq_fd`7ubzy0G`&333c_zB^MNn$S5q$oJQ^IVc6Zy&1sDBiwn z>Ff>`>$wn50`|;9YpCKY`+5AD>j0P9fk|TT5_6yoz1^rC$ndl&-;gCX8NAlMwX@g8 z8ETQx{sp-eahBrRIw;!50uE{zDai2Y-O=N+kSIByr{UY`&+na*e`lczX^HR`HDJaJ zMl1vW1nD(?zBq5su&&y!cH40CW}sHV<0Y*<4cm%nb2^ebdL*P@asvE$-(d zx{F{1d$$Mb_8CX|#0?%4@i0O&>{Q=gyILgqYUca#_6$s=BeYk)`>cCWw{KwAr>B+2 zx2b3XV@4IQkYv}o^`lez$Ys%L5F>o13?QQR!#KY$NBz&$z_oAoCJ%9b z5FeY)ul2&V#_*FdL+tJOxP3*3@mzzb7!C82>ZU62C@KJ>u{91JH%kw>|zKfc7YkX<7Crfpzh>e8) zT+DEQ1_NjdZ&2>wfI7{W2ec=fLgGa_Fp*|U-L8_6(C=WEo9NiJvz+t60297_nbpqJ zuOkr^dk0?t9UwG9-@j2;D$kpbW6U#Jy<%iug2nIo7DJw0Bg^R<$RvIgTbfg>7W6Sul<6bqJvug|i!r#L7R z6L?GR^Z{A~oOOTd!2$yx-VY{8K}v}tcD}hv==!Y$=WdTZG&O(T(t&OIB_lKjQKa-} zg1IRVyfwgiG^vFeonFt005li|4W}umss1lruE|DJ3dfUdfHe9}zdd7D@LGYq$+qvV zfU&5a&0`fm4SdzG)9wK_VG$YO5QQT#l# zm&rL)hTZ`H*cXbpt+d$?9(FhhaaIzX4`)L&X=eD{mw>2Qcl7v^&r zj2Er;ce~cPHIhH^x~7OfetMzG)85wd`i|$I$9FbApPIE& zhu!%DDqg9eXb+<1fSVsaUzs>~3hp*aoHVcO2Sm3{8%psYN7}(S9|gF$`(9d zCO-onHETcH$UTyX?lUyL8=@azFBf4{!@iyJ!keCb`4-cwXnKR(aG0s7aA3@ zQc0WgVD8@1K6*Pam~f?c@kc4mJqElVj@L+1p7lEJksKn!Zr<)r;8xtW(Q~%wz8Ax? z*%6{a5cvou;ehB%6L@z1@`gdD1))j+1M=OckBlUAQWLkG0sKMyeL}V;7Cuc|+wW!qT9gGqi*Os_U* z_2OfKd#aMC$ocWwdO1P&2R4a_l<6!DxqbcKz}O#qS0bcIZz}xWBy2{%I^aT7&u3AI|EkyGWi(aQ7sFiMU3)=o6Q8e*9Bwah2THn}p2Ql{3~n>4Y@SR6BwmVSR3iE)xWMjBTm2J1(qbX*ElMYE2OG9j) zPNn+d+Euznm@qn!bl@_>2MyY}i?zh)7L7?LB06WM=qn zmf;_VV6;koUOQy8JC+cA_S(tOPMfNXZwEaZs+_`1s0gb7i6DqG!F5X@T8^ReW#n%$ z1ExD&t2^FF$z`%wy>=gUT2xsVC8%r{mN8-~G?gPygMkJk zO`EpUcx9XfD5b3$&gd8hW((04lDS%?NFbXthLnT8}ggLPehA-aKhL+XxYrp zg*$Y0V5^d%U+DRL(A}Fa{#r5z@ts<9!FR)UD^d;YXFr%H(|Qi!Oel&zVyE@ZSRwT|(L3 zpPcn|tZAwa1Cx1LuE9^m&mjFd?VeF%{LYLym*IjFEaSd3AwqNhJ>LyKPKm8_-@!!^ zTBsepO0a(hkjk7BU3RQ3t*y%$Z!~?)xsBvRU6}1s zad!!)I$U_vN&QGvh<7z|0_mP{8u!?D*1Ozk?H(<4c3AjHr5|VwwO{>#Ui@|2Yj&u# zz4YpIaYC(Z3QGifK;-b;zRFXrlowX~Ra`q8>gJ@cMt@>Amv%le<5J!yWUREyX zjB8UJE6P!C9(1MFDubu7x;Q}Cy6V*ewc3=EY*nx~@ymb{%?GJ+kkcKCfRQYO3W;?i z-;C(DUPJBtlFeP#=-FhMO5skSeA{t`yR3q2-4IZStjpHe&audFQrde{ryJnYfZAI6 z5%9zJ)n%&C61A7~m@bpO-Qjsj)w|quJ*O_0WWAAGadjNe_t)1uY`6nH*9VxH7`JM# z{wfSHQ4yG6`r=$Z)@Y3lQVs?CPLez|Wh8TeEY*bP#D~~E=r(QRehGiU0;D(@#9nHi z%{=7<57;6rdeVh7>MSyjZox%=O)m~ubor_mq6uSacC^;H4Cxw1jhQV{J(p5!GVqri z(>tW72$%ZaRv8g#^c3w{JqO}V+Ff+JSFAsB_8XSxwA`Fi$ym^SA} zXJ(*durYnp@s@76kKXbA+ewwjBrG7N>Fu1wM}w4Jn15sU#G5 z*V@^F^*VLZ%bEkayO9FJpi!@VAQc+LiCaz^K|H7pqoY?zG-=A&GK}zG`38r69=AUQ70vz+8jc#`Abq z>=^w`fb92*n)O?%?%I*XeqrL$%Exk!on$BQWosp)6w?j%JJ&>cn8+NK^jgiz}DoV;sTNyTJY> zcp5thW`@tHjanY;i_HbQ22O+3el2$R7)5Cn&(Q`n0A~~1_cw}i#7%bHCvAc4DqmC8 zDK@n`mmSmhtzO79(uhZ*vzx`ENW^46!LE@@P;{I#helcEfv?~HD5MWRrRYxL@7go8 zVVrXgn5zo_8ET&9cdx{9T<4J0;E?f5WLH00*C(Ew$_aZ2MY=O&3I9@O@CI&rSIaVya|JU}z~P!mwWn1^)pb8zxH9t`@|yuUXZd_mH(-D0 zC>+D3GZ(+m4|@?6Cmwor{jxq&Q0EDtw`f)6ymM-H4)~t5`iu%p^hnHwbl4}-<@NyY zA(h5vYrc(PXv4#sie>qlBjKW*5VT0{(khk*h6)CyqmhOB=_7+>kR>9jg*!m z0~~x^Ol9)Zv%SHxRI;lZgupjQPd$?GHD90p7|L2~G%y{Ls7~Ap=j%88q+M9|ytm|gAd?faScsOlT~wUgo~he&wd$je#|t>utrT^Cvp)F3k^dbK zUXVUVhG>A3p#jbbn&;=G+%30JJGeA2nC4a}x~EvXmMy2%4Y@VL+HHJ>VdKWwtxl3tMha-Mz@@e4ep2OBa_(?Oyt=6;!*hp9e^{nTpOe_N#Z9&*0dg{h45`upJ*PjCTD-ooATxS zsDZ%e@@rd{vcdb<_%Mu;q~m6l@w}W$`tX#DFdpsA>@dyK*nz;<1_`EF812>eGudo> zfZ(Bbj5bpB2ltg7rkqAS$TIFE5k_kCZ#wohdpfZy$LfBm6jV$S_fxyDN?7d4O_~S5 z*#)Wf(Chjdfi{PhlvJ-EH&d!NkOxb7I<>+zm|OhSOzmfDb7J{)_sM$FG!m^|!}m|4 zJ}UY==vYKI3>V60;9&78fk6hlAfr|6*mmA(44p$}$ef-wQeg`&n5lnoe-^xs3NURK zb&K9%R7n>qa1!kA%VUd?!&gTr>1DorN*?<7J`~0}sO5A+nbzTNa?ytwAdNWX-O=X& zZ$^pQfF9NcBv>&ua_AI(s+01qEm2|4aZu?KvcTs5SY-9~bkYl|bgfd=^vbChjgcT} z!bzx#tFx)NIv>wAKwH{RmYmvfLT9_T(p@kiIoAne^iDLb)_P&r%VEXwXBud(#E!)Y z>WONYS~l$l}=G)107GX~Y#gI{+V;o;!I1Oe=C=G)ZzdYgEM|Rth7&EV6!PQQHM=C} zoYk09G}86E_+Wjy-#nTNg;av6eQQin0jiB>ovUg~RmGB!>On#k-myrF`;P#V!Tu0k zkZyDPus|6zexX)pZ5No=3rCzq#hW{8uigIiS}O0|n`S^wQm&8N{J47D`0#au%vlqx z_=#5}WrmGzzQcS;<(HGQCpR{D^$CcS*5kXe8IxmdfRa()k|6-a-Jq5bm>jR z%P4TNSCihOc2li8fXWgEEO#C~QPpYhlU3D47Kx#?hCyZhj`Fv%BIu=`Kti*MyA(+u z`#!`tC3dEnNVG;CmAF!p9k-GbB`)H&Ua{~PGd)w`8AfY;JefG-O-^zv+UBlh{HXww zKkAz)Y(SV*De3cwMg&>ZZ&VQA@N~ed&86cr1SA5hz;gp8`BWka%4{vK!tLZrF9+X> zufq01;kFY?Q*b(H%DdHQj~<(KY9ZPZ&QGcr&G(}?jc3}g`NamWlJV8`AZ>RpS5Rw5 z&2@B*<2iLib1v}q*Iy=%Q*s=M;xCZBPCnr3Q-9CZ5_2E1#A(qQ7MoC+Gql*; z8n$A}18muC-6L?bKJaG5{auNvJeaw_o5>X&B#&27Ai z8IUWf@XJEqF02306#_}Waw|K>j99E8Po38j5B%9_Ep|QOe0yvU(_y!zpwQ1%G*Nc3 zHG9E5JYjuAtv3Tt4aKuZ+KGEjDq(XFu}v$VA7gSd&xN%*H{&fXj*NzXP;Bj9A;CcTGpX)&oc=|3}kK5%@U^Z88|BT2pQ-p~^qSnbP) zTfv8?OaS6=DN(r<&fBqnR(gL#WO|8hEjNy!c8QgNt5CmY(6ih^nVRZxIjG~B6K@3K zLv;$^WUcp(I>iv}xo+9@6$rvV#DClPo~h7ZwKke*+e>KXY~GnFX$$8LnFm9QJC99S ztMdXEiwX9_?mWT*u>OwwyQzvxQ@i)Voi!d?r=Z}-cu@Ky#Wx}@_>ho%yc(x%kuL|z zp)NRa+%`e)!{Lp??F~ythJ$#f4;#-tPClU!2G+7K%26F&hS}a+^nTj2HiykHX*u@9 z-3bzDnEm*{`fX)29MTGIqAdA&L@R0-G$=UR!>bHCI8;s-YdW*uGCTM%E0C~lZFY2F zZb@_OvMF=rY8J2*WLuSbaC;k%tP(-i3%3?H^hdEVj_HHg#TN(6Mh=5|lw(v}H4>^v zHoQ}HTVYdzAjwhLszZk^!@+w9`9d=Tv9;x11Kf1I&{n%WfR$x&zid>aeQH))xLq`p z17m1m$+2GoP;gC70|oKAR|hBd^l2P zC?7`lYdqQB-&9vrw89UZrWE#DF3gQ+U~^0i{{HR2)X)xLPrh5Mk9ZciI@3{F)eGv; zXBq^%`36VuL*Mk2yem7j)hdC z$tvu-@5dbQElP({Dgl7 zv|&Ha)e3q!S}?(9QI^5bM=UXIKiV3i5k>S{dIUhgq%b|W$TRD~y8RUM1iNpBp6~58 z(tXQeMbA78pIE?XpJdIaszn}8Rwdep>DW!097tjq=Zrsl)ePo@kxkckx3GVLyvsT0 zu95htb#yu(VM9)Mdt@KIlAZ_-Pmj2auG3jQ*@GITN>FmARhb-S@0@n&c(1!at5Jnv zS$(fm@JT;u_`|sunRo>PeF9jo2{o=`kfMSX+wgcrnB1Kken&Ax+G^qqCw!w%{74I@ zuK?J|>(nHXlhzuk48r=M+#J>Ja;vvc4@QFo7XJ-yzgXxxHdCpWuF2J>ULW0+L!`vT zRC)OOHm07~D)&W7M&H6$xOo>+7z$%tlx#b=kP2$zTs}77_uRH#_+Elp?l3>1N*OIF z5Wj#;Bsgj`ioQ6|O$fE)J%Q+)e(i_>Lpk~a zr6<3_r5mkvv+{LvlH42sN4Q1QNgjY^5l?%{^JUEWd8C&jqi) z=pt>hd9J}waqri8pI4F zJ0y-%WE{^uaSlAcg&_|!vI7f=m$F8j59th^n7;$$2iLHJ3_~B3v6s~F%IR>0Q8uuw z*Oz;&3J}?O^LRZbaC&Z(gt=^|iW$=W^ngUH7S0j(%rcf|%RnRz2K&KHFR<%Rg5q0y z*i8vCQSTH7joRBoGR9Kza>8sz`vc1$uXa=ku?wz)pP`tsU*&P9ThMZbNQ-Qmk5};Y zf$1m;sbR;^AD$0L2l>p3IiI>Lr*~53bc>#0XpEv%9+??_1$BAr$}Z}Uv>)wo--exi zQ<5MXS!oamqwLrTzW>~Q_zfTh;Qi1R?kvxBY~5rGV5wHJX!arMfEDV|IPyCU>_5<@ z+!3lnJ=JZRvDf~& zrz+w^f&bnZSTTOeTw&PAv)umhAE-nw&F_8|hpq5gyBU+2c+`?~%5fGkU#_A^?am6u zRK0K^tb2r8y76<`>7qa@;nGm=eBC2g4y_Z@UC9gyPLnV+M2HYI5+7U0w5tWHnzbBi z4t}0UeV9TbeD+OJf(+d+P)5hYI;`@z|A8vbQgX^7{n=r&na>BJx6!{!U#A-yr*~u= zr=T6`E6@&r3JR!*;ZW8Ne{bvoSIbIb1RcAEDI6;9tU}Mn108GF0ASi|9l0A}UJm!L z8{@h1i(2>FUyY?M&L8j88He^hRh5eBTh)DEf<%+-SI`kWD>iE3!Bf%PmqDlTF#u(HUEcw+Fa z4j(lPbMwhnaPq;-SC_6{YO#|R9b?SMPtD7E6j$(KLEU>NQXqk(JC`9b6Biic0mqIM z=;uKzyDB;2%S}|{yA|U)_gH++Mka0#>}wap0a;lYaSB9>4kok}a<5ej1YTYfeoj4F zn49(!1N5o|%I!(M4cnJN!5do`zuNP+v=<{5zuEWom4SVo;-{5(nDHPVjNS33#tgyv zH_I{FsD9>xUN=Dg14tuI*M~ydpU;1nB#qv=34mYNPIrkS6mqEi^i`81s@Pt_s4CGS zn4nqh(ua|t`S1q#(GsZeyvL}t7PtF!>S+78^LiIu9xO#{-)<^V9NXAZssL?76@Fa2 z*~*W1)8n@s?dl|Ba0h`|$n+p0qUQnm;TuK{b@BJz9`(F3gxGuybj<$s@3*O*{D!D7 z&Wp>vBPmsnM0}SGU3#!dVJjv}D8)Q=CKwUbkSt)9@_92-mV@q3GA;sv8@!HgV5pXM>;XfByc|dz{2Mv1FYO@-tmpZkUsbHt`hhRaw_G`lI(uzoEjTf307@ zsUB?eT3fP_FM+Nm4iF{)QmouC9TTzYo@aR)Vru2d)8l38sd+=Mu~=w1MI-@n`(xnb zxerKI)~UEiKY;=Ou4cq$QCi$WYz9Fm&-HW z6ovYn5(dYcad(_y6WJmv+h5x%3v@UQ+EyM>^7KCdhg^CxwvUZmKlVhM&m!GXgNG@J zMX2hx?Zp7<@~h4mTlSnoR8-@%;^Y1j^t4ppl_MdlB(LV&tx4~$TnY9SU=n5pHZN9E z<5R7+stHyhn#NE(g5^RfD)GiPXm&9MY2= zC#UZgE@afCL1U?7HgcNra zk#`&&e2!_DCw1ewyrc;~c0Q6f1ZHxSNMJVZHUXJpzDei85g7^prEWDqI<|5UX!B_Ca%m|fr2gHw;%_?} zeK!6B8!Gi)+f9?GRM8QjM(%H%XnmR($B2QXbxQcOQ>C{n4XWHv>q};7yzWrZ z*^HLhjZ}`M@qB>&@ncT3fwaVTgNiAqQ}hfKNh@MAIBZb0vuYHpbZ~B7Kv~;#R5qaH zC3`Nu2-L^ZwXOyw5X3>mP#d}#L zM?#l(N8yFC3BqR6i;rPNsAsD{5mch`mM0dF7xCxrQ1ZA%fB4S?`Ac@(Chhxp&b`Yf zS#^nu0Cdd*71iLJSLsTL5A0qyC;&&knLM4|csp_%?y}waUZRY@G`!gGo7K#AAP#rM zc>3ezj$daii_vq(XGYE(+JDU5Uz8H5jf*>s%UXTRYDTTpx=;DV-M4S6wx<(6D$Fc} zIKz4}u=AXtRzSr5$3%U|1#rxF$hx|M#5}GH@4A8#3YfDyOVwGKdCxbmLD1mN3O}XD zAEv{bMpw;|L^H^-I?Os+!F}VGihYUSpa!7k+^3|w-;I?^DgSR!=xPE-?iXSUyGoHN zYppu<`l4xWwQ1lq@v13KtC5WVy9L)@GbDKhz_PAOGU6MP-(yjFa!|g#$4ej&aOrKn z`(`7&Ie4lcA3pvg+H`?v3mBmd&`A0EX;-G{{9LI)__9zPlKc(TxW=6&cHybs2P7u{ z{;B>l*OLfazYwo9S*gP7<^oFyjB31kH#NG{2~a-2grq0g%2i)t|NU;@IzRaPZb$tM};1|EhYP7Tz^4$AEh|YHXrWH)>WA-F6TK@URFYC zU@f41bkA(&1Ya-ph-w1E$NqJ>7k`ZJLo6^pwo2qq@!{y!6mT-83ER)tlNbC3_jDb~ zvgNzUxHa=2ArH~rn%o+^FW%#a62ub!>FBHgW>EjWI?>HnhVG}+ANeSkSWkPfs|3`z zOHN<+`L}tDz@uPuSx*{?gLuE(*++kAE@`_o+O-v|g=8;QOD$A=qmh2qUj z%@sSE{Gk8d0^qMzU;(U6eIxjD7^FAf0Cea-@BL>U{^ddbCm#M@OaIKnKlAXXE&rc* z_|M9+{fu54Y|w^~Q*u@QAO1o}46yb4vZY8w05W2&m*{Sw9>V z&mc`V!}Xkguxl{UjTXr58hQQ#zuA3GrSsp!Z4Lkqw*918RchQW=-w(MJ$Vc0x&WH* zrx;x3*f_GG)}!|{3!w^dZ^e*IyZ|?HKqVEkE?*SOsyvxDayvj#rsDNfL|#2RrO<;%ZzM0Qm@P{aIyRIM5EK;WaI_GCam0Ph~kX@Z(rfRu6d&t z5PfIGZo0k_kZiXnPC2i3)T};ekDnV?i4)fPZKD}ef8x&b|9cfvV%(>`QKyjm%&`$b zGZ2tLQ|A}kwZ=(64WzJKpcEsj2lO24g0ZcO^LhV~E=tnxUY9zZ5ITF+?ITnU;GFuE zo(0d6J~%6Hj44OcznBw9&wNPH30v8(0@ryT+kNr7rX$_{oB8-}LV@?1-o<-GC8LRc z|MPfqwk%N6b-_w)+YKA*eRl%crQc&DOlm&cX#aLb{p%_~Vl5?*TJGM{dmpdhJ?0_(GCw^!;|*$Wo1B~#?-^_aM4dAU)Yt4d{NR<$(Sb@h z&7Gq(k+RkIJ-Umy<1N5_gB}LN?)$hrp+B2`@LZR2;UGJErAYNgkIPS-S~=In^7c}f zrT1qQ-r*ACR%SR8t&peHP~Qx)!Ie{oh(xgnHe^=&^7?JgFke!4L>Jrs(6mHs^|^A=lT5CLXpr0y zugvWPc6}z-E;742#~v^x>T+My)fZEqb8ik>yZQarT8R}C)RUEV0W|R*Eyc1UmgbLs zB>pfw937E|Ui8O1O*G!FfG8hH*nLH@U7jl^u#ckeH(;{67gtkm;p%AwR55;Bx(tl` zW+fjelxYjLcvFlQVkr6ah6HNRmPptcZ0yp(Y;dmGTn=s{;W9gdg z>Y`dz%lDrbOz^z8Q%aul*fyR&Rm5TQgROlRS!V;uijT1vqPwCYUefMlY3<%8;jILo zGDRH|fz5GdiO#r-k!m;B7q0-9lm>qkSRuRCs~ef;Op!XmCB;_9^bmz~Z#oZ8!MbPZ zkL29)@vw^8=n44m0)5Oz8XCy0V>4|N_A|S|`uw;)wP*{#ltF^#5Mv+7n;_(n+nqz00+q=mJ`M{s(+~v8I;m5Re=CxFE?~ULV z?@kUXzv^A@X?kGLJ5wQqVx8mua=g%O@$==gIE#LvEzBnR(9iCGTHJiMx^q&uFHiDT zkp-777b21mF%(e$M{}C*tazE@A{KO(a0#7ArL`d;;VzD;vY*JUA2omrsPP0M?d`IX zq>Q91P6HJ2GZP=d3(W7sO?qA}1 zmm(HG3%qiO3LUxUvd_VQ1_E$E`0k!K-V$Y+LW-SvT|C-;cYn=lQa|*;sapy~>+Pi~I`ogWk72M=5<2Jg zY0tA#08S(CN)fScBLbAzuqT%XHMiUQQxjfI&B{8hgP=4=K{~`*TM;c|pI_ zTyr58IU!Lv#s$_^G|V!jG7xmXXp14T}>xT_dDa5D=oUyxUJEH%u6{+VZ2`JJpf?Qjnp*l z_<&-bEw|t^ZLuGjYP82KHLU-zu_s)Hpi5ID(DQIsurAXx{j5(%4>e*h_IQ};H zh>0|OIp+FedW)wDy;glS3tEYy1-=DvC~n;tDG=ltW&Jpnt`I+;+oY7?xzH2H^CIuN z0HfbUS$7KW`G{F3z<&K_*Lo8q*+Gn~aPO6`N+Bv5PF6@w7vQHzP5XKvs2_pY}kA`zL!l$kXQ&h=g!JA8!D?c1yEB4Z!kxv1LO^4J*N z3dr)?_gG!~CO;IJA#TkH9({bi9N33>mukwj;$d}*{jC02yCqr6HpJ;#c#`PM(`!@z zU0FAixJmWrQ(c-0Cu)1mIfm|a{Zc3@`Jg+31GY@BH=EZOD}|U-Y}s;u(qcZt_yhUP zIwy&d@NF~Mu7^=a#0nDqh0NWy!LL~Re;cbst(n>L^Adt(Qg5RUD%;Vtqsk@=>4o#V>YnL%xaXF121dQT0 ztj!zO3v>)fWIJ*2SUKGiF`Fy!1^pRze-GZr7=|9O$sM7K$fe0FfPqZC*&UNpZmBV? z=4-D1sFuUpcTPs|mNeKJ?114fUp5O(&4%WLiYS*zcpixwz~UkAX0p{F4_DaL4?=rm zpI$f<(+*8-6u3y}fB@42=vjV=v8QL(Q==t6-+XI<)Ed$vXdySE2VpYxD*0Jwoh>+= zFTBDmaSj)e*gsY0jMJ?cUiIw!tFOV|0RwQRndE&Fa{65>_K1~Hn(JmqVedViCe#y4 zP>`rHn*H(Lo5By|V^vk#b9-iCTYiJkLVEYTu7qF$Z7Ad(89bn+F*=QizEB%kVf>h9 z$SnC1NCN7n(Qz*&bW}~Ub&TKT3*3t6=8RpL76z<#w!8TsdT;9#xx$q}YUZsK+^-)~ zLC6W7>kU$ z^vdOZ9BKJ}g6brYkSAlx`UvplDdV;FQH)g117I5c6cd6Lj!6Qv(6ZLY?=< zwT6n_i1c4m8@{y}1v;58O93n5!y=^UmU%|B`M%!118U1aT}k3Q!+Z`DYWd_ zMC1iYixeEcHIL@ZCeczI5j%1-^(enSo>Yw%t=_rzf)==&;5#JV5BJUc&Fh43&5*Mq zO3^Ta6&q^Iupv?@XpA}P-HqY%b&SS^jq# z%isYXD18={tsJ@|wG%z>$kJuQ2ItF`a9p1M8Sc0&5dWipB<>JRq}883u_u{Vei6g( zeWM{FNfBmSw{%r>%IUqA(BU9mPl^-fN8kRyQ!mfop!>fwweMAUnfLlS_3@5E=+biI zhp#2g{hc0OiAcOYWE1i>Pu=pEBR6|*LH8K3YjKzzpw1Fy>WJ4c8JaHIV!m;` za!e0YZhBfYMRM`zzSz{|N)te`h@@uyH`673O?flKK}FaKxjAVn9fQUfzdVw2r8npU z9D9H-Y*2h7WN5xj)1QljJ8W3~^vj5K<@|K>2nj3D#g-3n%gnh{*ApqGbs@(m9YL~+ zv;wv&GJe{s4_Ak4Zu2Bm*r~7&3Rz|ZX-YXF8tjaNauh80Vd+}Zd{$Q7N(sCl`D4ib zeOrJV)I$o4#SU;(V>VM^K7%h1lzsA@gDd8@S0ETAJ;$l14~1Nh&H-}C#=@^hYb zkVKKY+`S{{X9O~p_S}0o%ycHBuYW|u?X-gN^I5IXwksK69iMcBooxa1(o}9~OwJU5 z3$Oie#c-4KYWcDhQFM2?R!gt?-1?zmrRT<$i2DHs1~PtWLOW&b4Ne_WTmx}{n-7^z zJTDElBuyj)ECxjCvb#gw0QY)b=Ugw3gwD>YJc;R~DT1~b%TgX|-n;sRQj_Gd_i}K# zo*k+R6?_eJs3Ir)M-?D`_vYm+kPSr1heKNObt+?(URvI?15#xmqk>5n)2o(r$;we=cMyKf?mEC+l%EAJ0hzlc_;`H178W>cNs8uxpx z?c^Q@|5yL)zqUF2Pm;UA&PVdEA+h1ImHFLD2SKZ5wH<(?9Z>pdM8@Nfn z1b|TX-v=|$1_U4bMiJ}Jb2UNP(46z>!|u+2K{)`2b~L!f`|$!`K)tqDKf*W+e<8<4 zM;ND$A^;YpT93}qYb#k)d&+HdWCItTEUdA^HF*HE-nkHU%(sw!R+&?ywC8uY%wv#%FaD=lNS%qD08OJrihY);|4eZ@SlNLNP_1692*f67X8Ia@Yxl9YIYPqU$J}koJ zkD&J9Bwd=Ho-7ivd1U=XyIe}If742n%~a&I*DOFAF6BnoCUA}ehX_>Nyg42&1QbSg zilsY)BK0b0!lTw+vv~|0=XS7WhC~3dW{NxMhEjOf_6wXFie=IYJ9e%4A%a8p^&yu@ z^KrS8B2L79bS%9Mrq=_9?o)>w5=m3qa&sEcYqo`)$V>QIk59E`B%v zteba3{O460#Dxk_gr%zQV;MZlFs(+a()TRP2$#CxKyL;E{#z@X8uQj9E}QS;phDhb zsiy5|M{EY62mTFHNHYwCMCU;UP;mg#G|?3|U(s8pUQhv#%dY3@?+xGNsFQf#Jye-j zu0`duhV^NkSBYo_8Ft0q!@SC*c36X-oBoIeDhr^UgH2uS=|}VGYTUi-F#DAN>9W242+*DsX zjgzYnQc3k%QvkX+c%rOk&bJ%RkgfM1YkVPAsa+Btn?HdXgh@@oyG%tH(SYIYBZ3wf zM+pdbeUhW9W4qsCgR1iz`ypK>ULEe`YG`ce07*=K8<$%=UX7l;I74>FIGm0wE89MX z6Hv1K&;=M5w@nfHc7@N?0JH2c%!;G za|~M_e@PG|&f~LoE5Xp0ujhala31hE`rD>{ujX+=_?W~Jqp*%|dDPc)KgR z*|cq&CUB+hbaU$Wz3JLP4SDnpl$L{G3|jbPWG~2U*lr#bk2jcS4N^Wj*BA2#iK!YrvfX$?hubx__c|VWE!!VSt zH6x&?{?E3feqD;X&(Z}A>!jMLu^i6vLWkPX0MA7+ERn6G+FvfXR}@q-qClqqvhdfq z;dWIXUr$!@nT4Iag?Za6fSfy=Pbu+T347f3Iz>%=QFAiQPy%^>q?FprShrk(*#L?W z&&~BFOr@kGUgt<%nOG)@lU>E9U_AF8^a#wy!mIaLvIROoI0OGi=1Y|{i` zLO7dPVw^>{!XEm>e$(;oggSF-aL3WuWQ2#v{Z; z$U?rAq)63f8mV{R`6`J8oC%$s>+Kb>TN~DAKk@a)*N`|w-(^b@(yH!@iA-pPXy)4$ zB&)&7+sQ7-eAaBD&pDF1^6K2D(k5=*GOjO8ZHkqTGDgP+oN|{%$GIw6|IC#jdi3o- zY;i;{AT<;!O6mUQPfKjt2I zRTOA{v>zYW^+)yvbO6!>L|8cr$!`Di>Qz<1y&{Uv=KbR!A(f=p0tS&6oBQa0Uj42C zs7X|X@`(PE;r<@Ws|0|p&Cj;{U*>c7H$ASQ!tu{=|CwFVe}dt!b@b0t`@LWM6Ab?Z z!|yowOBnZm3x;SBE#e$&5o-5sL!jM%7q@}7?M$uR*%7gbo84rk^~Uzh)bZv-sryc` z@0r`qRFwq|x@fU;c@Ye~L-XWv#{CU0AP^_)vHPahSoGqz5A|P%-uFt$?NlLWbPxRh zu=kzeaCL3Ci9`x7($r`ndW+~?A|ZMt(OW{)!C>?n36bc6(Fqg11!Hs)y^cOQLCj!u zMj4E=z2*D7*LBX{^XvQL8nb8by~?wmcHiq+N^;fC7RSjvx--pkht3#ex|I7*X-~il z_jYOUdAf}eF!(P2`Eig6ldDS*OAyZ;ext(Ik4BBJ*%JpsZaS>BSafp~>&f~q=8uC$ zk;b(;TZVy;-=1S}y_2UWIwGg)*wn$C1vu}NF z>cFD=idy3K7JS*z&C32jELobvi2dA4f8Oe^b*rZnS(&bK8X{TzFj9VJkKHgBQWBoN ziEsHVq$@Nn9HdFFey-2IQst*#WEF$F264Sfp1Ky2BeCku)mh1+a&iu_8}+4IL?{o?jHkEKJDh#FPm z-p7?A%=wai(r7D#wAXDeMf9G>+hNVwV?hDKX-j^fV(LG)1oV?yTf`nsR@f({iVAhm z;*)Lb<-@yWj&GNy#08!o@PZY&gGuT9-3#;~j@{ksYPQ@ms+h*Q-kxWK#uMd7h=5!^JNU*|2;Sd6>o|#;-1k zMI?{2rFgp@AH@3|^%hZK{#6t}tT%!LD!^O*kk6xpaL@s5k zDF>(3te77(OVpDc5@Pd;qs0G(NDr$2I>~!UN&3yOS1OfHGJV26P2njn=C@oXfa&RMdDr|;>~iL~-cStqS&lhIxY*E?@Gc}hW<>hR z`4h5JYajcZRtf)Snt+ymV*lf?6Qm#|(>XD);-g<8EuN9qL?EeVy-x%A% z%=!oKMgML(cU_)YHbCywq-2S?q0B~y8$LHRsGckqxSN(W&Z_8sZVhZ|ziy65?G^>^ zRgiYtN&`RKj+*)YR_5Ap=(#A+gBWVhlA>BrTkAE6g0Bdq0z2aV)1QF1*8@c)udOXj z)vQg%;CP=4oRiXP-{R!;U>|^%CNU@LP|*C}?z9)Lv>7daD?@tzoq*7?-9Rld11&;L z*AA^*gq5G4*eKqB?}}G}C=anQ9#d!);B~G~^2|k-o9Gw2)~)N&k9h_}M#w5NAN)neM+X@BqP# zzShoGN_`meFjsTvTsJN&10^b+xwA$z1mZrnIxXKhA4mnTUT%^cyFJkfeN+ENNjlEw zFE00ABOf6CXBWi)#GN@}Qsw4-R z%O`w}dJKrreW0Kk|8k4)f05F&PxlpoZ)x*~KmS+A{`@Vo`=H_1GtpxHLBwR@_-O9< z(2l|9%~T1n8sQe~3k$C5$!YrKAN6<8#CfCw*1eyR{XML|fTLrO;fxrT zck*7O@Lau8rRFP0&{*xMylqo8K1N}XD^#FW3%_-qe)cZMtWo^d-ch@cVqt1Rc?ZWK!<<5vGZ!@2tL90h3==4ZL2| z|3a0|<+74@Lx?drK>$D9=8k>7Mt+6zGCA7t;C0i&Q`}(ZkU<+^3mcGGgM)K zkY$b08@6$rVM*!hG->w=2Ya+Iw_%&w-Oq-Gh^abIyvRP5{h28z9KjE zWJ0$n8-Bi3jXcsY5F6PW&=Ya*7@yp~;eslt4IYwiybGBw;KT z-wKT3(bWkkK_gH0_h*w^3$GI!SdHe7lv;+1YUjs_=SLj|0>;p4etS`JD^$Nc9z0_w z!-jNOG>;sqn}yhp+8QAWaaxHLmc5vAOJ7lQG++zD@+C(K$6Fri07S1eD?dW(gi82) ztAIoQkE9*cH)S4#T`5|8sD_#Y%43BFPXbk5P1}-~U~F~7HrmyL;k`*rrkNfUX00SA za?fV!!Lx(C$-#Ze`{)*B2&%ZkwRy*CP)Cjl(1QWHz^tp596bg9S>+s)Fxfst9^TD2 zS$7!Us%oH{XNSpsO492Hm1zjrnj@!`|I@DBCl_M^Mv>vLi-mHW|00ceJN@ zdc4Cb7e45Y8mo|qWUuwJ3MJk_du|T9A$^1}P$*vyJo>o{wP4E2-zeCd4@w$R>Vjcw zcYI?bKdRw&Jc9Sq)zY_qP{{F5egCP?ODdr&Vq=ynd~|YrNP-c5aA4OSAtnmW{>cVI zMw3!Y^60u`kk&2yj$w@`S+Sm6h#2aN$w?>7mo&=C+U`Q9Yg6N%^k@Zny>hTa@y^3C zJJjVOE5WBNOM;qVjP*c7&}uv?D$`kLJH5AtbVR!4>`qzIAyZwCw{+8oTMFHJ5%#6c z65vNGas0R^!Q{xNpYM$82eiN z6QGA{^6-y!z57((^n*xZz*RqKyT|cU8D6+NvFR~%6*t01_D{1y1K36B>wqq(znl{K z)ta~Ce*fE!`lJ11rnsdS*aP!aqJuVec{ols+P+5Jy~wkx(!pdS^|x+ ztN*i|3mh|!CwbZhZKiQV-!5X(bE-?W(r@{9)@s@mssA>Q2kYXhwnmK$mik@lg^eWp5ODTQI?J0xc&(*=zk-5dhgkW7BJNA%y(pBdCil7r*X20JV=i1pn& z0U`#_@OO2mlp5zO`#G|w?uhHJ@$A#5`?D}tJ=5M`qroBC?zM@^jhN-3oMIypUtB(A za^*0$e%YhMK?7HX1v^V^bS9l;gi!QZhYTQlYjR&HReDn90pcX@i3M+!3EzrG7om<&)#}d?reoD4txMAK2i@?K= zg6wDglt!&V&vO1)9v{+&NJpv^H*Xw_uc-Z0>U$*2If|}YD3lpx-R&2igA@EVXJd9&g7jY1XQ00SwHBUVDK<6?NkX=V zw(Cp=rO#*UdR1^U5!r1|4QnWcGR9WNP5lzf-KJNH3`ZSi9q!atXsa4_ z8z;J66SEeI#pbgo-C}21!e)4%4xD7k8vS`WML2(-0fU#xQn0KRXrW z#^sCbhF9kJ`>z6SP0%}3OuwWb3My>6K13^2B%y)@)v!o{vROzbZ*8KripGkG`FbK1 z=PR~u8#HWXi$Ke%XoNv-snjOJoPi=j29o1ACD3S$4<9Gk5TrH2-C?u4&4bcI+NoZg zG@kVvT?rSfTpKsH*^cAS04FJJpfgAXs_Mu*i6d%JcwwMyMOn_V8t(?LtY?pu?C{$}3g zI~y8=lfAj-zNOPNzaBlLw920m><{2Xhy`W9kgc*f`qg{UotByuA=o~NqtWzbp?l~r z>=WZN-(TEupWK{7es)8-Rdm&T%$XlY@&mqs_o64huEw>0w|i2PwS_3yUkVB+;oNQxxO3xA znbQ)XAA=em*(zTt2&Kv#{zDKH5smOA8!`DEPeDiD2bZrG+WVa#h$OL>=q#Q^rBpK6 zmoRgGsxj+-P3C7gq0)H$cKC9iW=}?>3Pm<}i zyC2&q>flqeK+DI&AV@kf5%_TfnP*(9T4W$bb~jsFz9{&w=;a?*<%VCtr;_^d8H*65 z48v5(Q8L;Z^9>W&cdA@?YTS}DgR2S@KJxk`VHICw@0sT2)}Yb(VyiN9D=}Zmfr}9% zWw#|Z$Gf~*Uaod5F;Rr1zv6;g1t!6LOts>4Y>)cozvUX}IH+ zhntdgtsRz}ss3E2r55wRbZzkIP|A@yb7tsl@)uyXjn8JgI~6T?vjag?4M7i^VbhEn zW@3l*0wh$24lOGEmIt@QzD10vJV@C=5DsdSg1&$%?T6GPu-#f@UH9L;%z4E%xwMpI zKUvlv9;o3o^Srkw401?irGQ#INEONXrL%GoveyMQ4^6aJN}b*#X_<&BwK*u7^?~8c zYf;5RwN9{4PSY_~DTKqqM6ZQmuRrbQg)=YdEFn?-gI2^gmh)9dLFll2IxW&>^=a+h zQ9fMX*C8NA1nF_@HP~8eCTSN&iRtq1!2WV7yb@rPN|uEN_S4gk#6yDZ4Fi=x0}^ie z(={;4J#N!q25e6a+Y%{l5p!gwWTEw;U-iWmAB*(q*P33U_ENj;s`zZx52wOT0=_7@73WYqlkF+L!B15!-biqh&6E19I{6FTL9 zI)VsQ(zS-RuV-BO2{%z)eTZ=IzILm~n2%A&a%zfhWifNMs1`__9Au? znkK}9ads7Kc&8_v3YqCZz2kftVAoR&Q4_>hNemxM(){-Z3a2fO1%T|UA2$0 zNTtmc=vNR}_H6z7b7B2$Ne+B9LB03bnvLI9P%oqk7s!z4-HOBah0qd1bB$GSBh8IFT;*gLA>!n=W?+mRMxdXZS#yOUF%j^de63s14ra6p zTid7^v3HFDG@kc(+K|$Qq9!n{^V8_FGn^y}MHGh2!mYt}fX|U zpD(gdf5@P5kVzvoyQ3ePSQqKr$n>V5&a*XS2;8vT#m*r)>pI+RY>?p8gd3#=Utf9J zm0nyozY{8u|LT5IHfEpj;BiJ?g)^8buCrTLk4M*F@`AK$+cI&)l$L_P<%a(1p;25q$^vI{XR=& z+3-az{6dV__+A{4OowgM50FO#9Q^SoU(KRf{HI-mU7em9do1Xhq$d5!q?0pos&+kA zkCPVk+O22xdqBGZ9^H!`(h!m}xSe!8n;}ZqZu~{Tlj}t&YqHmctzmGgTfBz{w~@zh zM>p&Q6(br#4R!qmRcuRd^zOd2gE0j;qWGG;L~MnB9q+K{N~ zYf_Fl#Fl`k>!r-dw%&5125VR?mBW+L22N#m%v)N_*Cs)iM?9?;p?tTYe5P=x<}cyN z+zf`_l&iPHyW>Xi{Kzk-K#nMrpttB^ z!<=@_VLGr2RIm$l*gvRw-0oEzy?5h@dS7)`N$;Clcc zG`Z5xH?uV5f?T3(GZoUNxBkfQV1Rzn+irMC(V|N+r6!xRRn)b=j@5gAV^nO_W9BO$ zZYi_X+9VLFWo442gzRK7|Ln)F3XzwHC}EnbVVd?{dd=C zok>>JdXuJ{uRzz)YiJTd*>=!)L!jZnwR6zFEHejit9GHR^qxM$OV%aCikv5>ysb38 z4Ft)7MS@8FID8fxD&`QTW52ykwyE15+4uN%dT)Z~T2#h>#&F$~1C!tG0K6rwxuAhQ zvy~AxV$(aSI2i*Ks6@9s!!pQ0M#E`ZT{;YJ@NL4rqeM4^-L4WQs!F@BGCi3jx=-5o zb`>oi0%Y00MFXBU-863v@Xz&YV>6CI$YF6qB5iv9{8qlyi(z~^!_OxzO#i6anFO2j zCOB0!_!rHVF1(MFQ_mFwB7{}L?CZq$Wn}LrSjXZ9OB!95W=)fyTNyNxaBg~g5B`Rh zG-e2FZN+LT(gw^PjC`L-_b-(k+`fC$a2PTO`SoKx-7jDH+f7oMMEuaZkm;S1PYmAs zZJd_$6F}|)4its=&SncIs*zfo@xz3FzAeO8LTQRTT3V<-GIt#@_*}^xdA$3wl0q&5 zGNm1w>f(3m%QtPa7AcZiVKr!Q$>uC>_XRBD;Qq`21ifkHzM`0)@TQWZJuGi)79-3jGy%(0{w$fV#D=^mT(0UowD~&N{Rk@~ zEHsqVkB`FFHgka+vh)U$m^3F8ZmcOh#~xDuM%eA3t<^z+d+#-62b!~WIbm^v+vR&| zZN)LLGl#(7+V?vu22<>k0IXue{xxe1*JZR&W>Jx=FaU%;Kq#`&Kk-CW;B*XaZ@Jb4 zNaJz+U{|1l-}A{9t}5-zncxw;E{v@dl>5BO@n~g4*h;&=-8!Syb<%yQz63~DTXXqq z`j}OwRlsU`+_WPLd{#?aNvQ^9Ml6b_%CtjaYQ`0|HK9Rb#2!mpT)K!|tzbsVi;P~c zbV?4L<{pD`r#4p0OYFVAjPF<%pc9B=`^o(#qKtWzQ9Rl;2Bn$!m+x(LF7y9Z14mV) zS=*Gz@6Knree6$j_TuiMmQ|$qkx6!!%6=@DjfWCxlV$}cf70DIAw*jwx_wigS&b;e zh$P67UoDbz^!*vCbVDP6HxM!x_$&X+m!l@i_ydz*w>) zq0l!R?*Y5mi6UQ~2l4=f1Mstt%X2LLxfgH@fpbiQfHEzl(i-O>QK0bnZGZ#Wmj?a! z1E_&~691++<=K6I-hB3FQxia}0BFt^u`?$05I)N6@te@o7z8QpSyVN!=LD0n{+kcZ*F?Gi=0RP0{%w+JOLi3 z$7LXa~`Ih{u_?slb5z7}Zq(olAbY3u&F$<&(!$Ksd0N%eGMDmmVd|IKHrkS|dTze8G8%-&BKD3@~k)Pf`BdC48a zkgNDd-yr86>B0FN$h*&k-}Tme^Pr%MYHkXM!%c^FztOKgqVBPZGC5DH%6sRQ-cY=( ztj-g=4@WWUK>3;^e$Mt6j`#K^U)>__|JMj@zWS9-)ms8a^$c-eGSGLsj%o2XEPzg9 zM=6$kzZ#xv1M{z-A3-?m^h8+}wMf<@hH=g_Y3i8Hfy)Z5f)%oVDkxE(Q=iWw-l1fx zF|jYXDY?F+tX$FsiIQO6&VE-*ygFHdb@AyKt-L<sZrwBAdiwjzdCL%R z;o9%6XqZKo%38k8+VgsYoe;`P{j8_8K>4*fMl~aZ&wR+@kZy5Oz1!MU1qe8tn=<2u z{`nb=dK-{QOghY|Q|GY!V#0Yf(0MUQJ2&e0m7zkfrfM~Zv9?qxYVcH*b2n7mu(Eiv z!MI$Ia@u~n-uoy`Mrv<;x&k~4IOYJq27J6a-FVvH7jUVq@rWD%jW$gI3R=%MGV*h) z_J&EolmrCbl_(VH=e*c;Af{Vf7wo^ih)k7pUk4V_l5rJmXOZWNT;t=aTR|jovMWm@ zz_tfYqbj|h@8>FyKj3{D5jp>N^WK>{{uK$h33BW3GY|Yf7VBEo9p)6s^^WCpUjgru1FSls zYKZ&%FPVS60NeyN=A3i=2i`?EJEvknqu}2U_+MlHH?seeSzvkmPYnJi2LBU-KTXd6 zEZG0F0pKP4PYnKli$T<6y5-Bs6;}$`Qj4QKj|tu=0n^l#_K^audXsS5tPtu@OzgXO z{mM5BM+yyTBK~iHjo4`u$KT%VL)XuRVejORiT;dV{FjCK^moFF?~2-Mz;{M?w0*W3 zq>_gVVwOG!aQi<>uT@k}uye6FFcio+TD;r^MgG^_Wd2nG5RhT`KGQPzj)<7t_DZt% z-g>wJ=h#&gcuLr7e#4SU?sV^}kovih1wcQInGMwt!r&LxFW5A54ZDw*)K;e|y!VcS zct(qb$Qi^PHy0^nPqs#{D;(83t_mtibzhavIVgRauU+`HYIw=2{3Gtt%$ph@u77lU ztQI^Aerd&PT#;`Tuki?b_vCL1dcZ0rQpEoGez?+l_ipiNy-m!Fm^XTDA19+2gyA>x z866U+!;PIJrqTQ$(wrX0Z25st4@bX{O1dpgAzUwbKZ^%4zu$>YWDk$W-DhUY?2kG* zL|IZuSM4*+|8$SX^_jhcAWk#Oz#i=>FPpxX$zt_*f)X{qadG*_WAT+jL{V*umr9Dr z<(!0uK;M02L5S+kZ?wr(45I(`uY1gheDP|<@P`!PD z?`SsQcxgy{#02#nL9%CSCaL<-?Bz8tH=7E0+Vn&;FA_9u*VG!`1h-+CJ93jG`nOPHH} zq!3PIl`{1he3BUCpDyDLJPRl2-%uxJ>KvWO#23A&`hYt{5KM4yq_2shKgEx#$Gh~U z3z)(`h8+WyQR=tGnOS|k3eaQ`B{ys9BFGP%$C4VwIJK}uF! z#(g0k)Mmm(ts1k3EDGXoayiIEF*6eH&qn{$0+<4OU4eg!sQwt}R-J27{o;s9T~n_5 zX;HqmnebcsL1Pa>ePr0KL{7!Pw^=n!L6Wd^(H%_Z%UId$=NTXY$v!uomf%OVPGLz8 zmJ8C`c#W%VM`~~jR~x`mJVTKV`L2g`txJ&ja) zQ9v1&e*cE9SYDI*7p#&nSX%($?G)2`rjnd7dzlHY#$bj7DzUsA9t<$@4A zRx2v~5~W8xJCa}?wHp<>E#xc0>@Q2zl@Xy`3mJvv0i|j~;dJmdKXJxyb&p zvep|ZQ{jU%fW3{Nmp~Cj2ixAaal~uz zxDLae7Y3oN8mC*+ulb1SYyVLCJMqQrNu+f(>C5Qbi4kdRUyx#YL976^Aav-)5V7xi z&EC&;^LFR_xBF=_eYn%j-;D8-Do}2d!Ue9PFdbPMe8m;DdEFOt`@w$ZvAVgh!x!gE zT29z#>}}|mt4J2Z;aBn=uKm~)JoOo+7j*N<7W%2Ec|84T@zoA(k)fqQqyAUx=q3{C z@W@LI#}hOKf{~U<2OeXUiirsIWN~we>Gw(|zB{|!C4Iusmuz(Pc971v5DFoC;c?=b zL$i)Uv00loXA==>%?cA4Fj5wKv*sv~y02j!HCCFUF$H%mc)Yt_IRnekwUXgNZqdKp z+NR9eYhYL8%^@9rQLj67^qp|)+p~wQqpJU8E`P-V)=MaQxKg3FRG7r(Hy2{yTLZBwwAUE!F_9v7CxpfVTBtX&ypr7LvAk>$JlplM z0j)=VWD|A^?{%_JvLFSwb)ThvaB;}Rz)1GHh}o4?KK=-gcO%BK30E#diTA} z4!4PId@vhEc-pAaW~wQiX;|p^24cIejKdn7=9f7xKkQwk>JU_-EF@Vn zi~0-I_;K|jLC%SNsl;b9PvQ&2tX>i^C^@{a=iR%3SEA(gXs4_%Zup*zld3snPd1D5 zmpd9{3MBv=eoki`x7K`>d^e$%KD4g3jT^Rc+?;zu?fVV%RQ}@oGrz*PV(F+rzGmhy z#K9?U^H!K5PUXu@WRYIE8{lz-)MmO`4hME7HCxI^`s|L;%%!jh3faEkLETgK(^nI? zYzr-swY?Woo96dssbiomqp$Jkk$|wQd*7O6XDpj=^7_Z%bYZho_%SO-OyzCPPBWw@gT|-*w_QQ6gbGwFSZJBOPVLv5THCaj!p9p%jo)}L z=(3Igm0FgaluS=-lv{=6M>DjA%#D0IKz;fGr`byBgx%zgSXjHqCeLXFBg;Q^cplw& zU1m{O`xPwRS9jE)_1JV#CxuqI2Wf|aRkjUS;8Cwgp02I}i(+jl z#*myz2!Lu<9CJs$;yVnj|VL=0Rlzu4y>~;h#=zf3*McgI~J*!y1}^ z33uOD=Quh^Fyok}ccJHEb_t*Gz z-;+6Sna_*EJAmLGdkVEOZtNotYonIx`~T3JCN0l@#s1}DrtZ~nRq>rzo&8zII6kNl zxy}7OteA;p&GSdT(m=hQdQ@^(K{nst?$xFWu%j^l8*$I1-l~Yol;0s6`ihGp za%?sy(R8=Mr>Ry``t3)y0q&6kce?8~lZBtP301V zS#|fD;%lS+xKVE;xv)u|m~=?LK;RDZ-&>z-@WE9RPCYvg6|KW=OZ-!3{pU^Qlu$%D}3bt=JKz7@&0M25HtGrnkWX&d~6 z_S)qxjQR>XLbT~Kk=;#O#yf|6l9iVJ_H}W!;)hoYmHAsQzw3&I==csU2wKt5Y96Qo z^}BF8DZ@*r7F9&i_)tt5|JVl|3PI3D3i9`RE={asXcJ>Bdqnrr9igS@gwU_T;8%^d ztzp_GVMGS)gGPfVTayl9{$Z-%ypyAq?l2YMY2kVN21@omYmr!RVv)JpRusd!2D_Y)*ia_q~uhNGbV!ojru z$GG%4B@k0jA~b0!Ypt=T;vhJZ){4(ETf{ME>&1fkV~|0T2+d~)1`Uy`TBb+hF&8Co zswYXbiw}qj=Zt@EBlnif-yAd&9YPtwBS%eretvcZ-BrRld=agV(0QnQC{vqa6ZXl= z&_B#JX1~sc>t@M%P$gX{NcGmUE%VR>|M3CQgwmrQz^2DPmG(3Dvlwd`H2ZZ!sqH{3 zETcEJNKAVOBkR9qvp{wmMBwcH5|%nYL)BCG2;`gq6#RO6L#R<}EeWkNVW2tN6PZA> zY0<{QiL-qrX8z$58>{76U!fm)G$o`%1tzc%G>=v4B;&*@^^GQ4@PV~4EyRWvRN1?z z=pwL7m5F=)*rxS?Zc6-3A<+GPw_1DcL#4FYO(&=uGe%dP)QgzQ4tnVFJ=8{ui0ft z8TmIB8}N?wZHXg)MsfxlRmJl_s><*7hxV_wyEkZSpJIz7)E}tkT2i7eL{pWCA@QDr zcd*Oo_dIf%X=cbhIMstegRUl{mW@FND+$uKNilV*&xg=2*kBxLqU{pB{OZY4nxR!o zFq3;jODVSuWU};|@rnUpMJwKDGpKG;ijAi&Bpl7)HNf9}ik+?q74*o>`%>aB$!Q?7 zvS-z-M5ELvbt1fQr~G?y_cIWM1>a>bx;@?6(Z>~Xw|U?;N2`A(2U`IfM@p!cJrJ!4 z-mb5sPTOpIsk|-EShr=nFfe#|7{utYnt}xnfojk#zdZ1YwEs*o^+jFN(4j?^UO(i(DD)Jayt-7Adh#R=VcW{&`=E9 z{kUqfk|P6yx522M3=DGJEEv3Je)Czl4%gTxJ$5(CZT?4VwlHEc`Q^!P{7-JHRFHkB z5Spbo87k{l=19QG+a_puOXMb?QtGnyTT$B+e!e~5!nSzq;~ZpSgW&9dyFNTu|FhYp zxNUa5VP){shK!%wp4d*ST<<-&2;2up$C~4?n@VGsGbU>rVuS0V3@2V(FzOiAQGm*E zGxD3Vml_|<+wizmbb14ICAYUtS|C|zvC52}2s}Bb4pXP|pf-P&T~HPipIc`8!EQ`4 zmO>dld_j{xP~~rA_=nQjDyXoV`UPYfLfh)CHfE@%1w=&6=nmE>e7%edqbse0M!{p& z^mvQyS0_(+s5A+)r?Xk3ym>!=koHMW>opQ$N*b}=Yn1pIFr&z=q_q95?+VF}mi?(+ zR$m)B4|VkI&i*!jpM+{4pHP|f*iz$pG0{{FrU-_#0ef2t3|`f5KuVTKm*Ca@E6K$O zX;Fbkri$SVE4x=q;XF3Rp!GE59J4jP7bQetdEm1?<$ ztSfC^NJ)DMyqSF<#L^x?q>R^N_9uJ_9j&$F|8sEEWHi@v0ai3g!It~kZi2IPIr9xpfbK|uNZk>U@j7s2 zC%Zbm-&Al)5BcV{HnY&tZPtch*t2-h(-|}2fd^t=aqNlLa38u7&ddAW>{Y_B>hs&^ z_iTt!3>;KW74ZCG?^UUj-~MiwbdOQFI_ir&T@Pmqg=%aaJiacnu||BOZJj^Wl|od4 z$9p(W_b{fCIUl&wgiY<8$$OWdKrXqcBf=Vu4Li-+sYIQ7ChUltb_G5AlJhW~_0JML zx8=iB`$=SmKKgC<7+0=r+(gZZ^Ob)aaWAghkN#-oKKgM14>s=NPyG1L{6hj(MI__7 z(q|5ACwHHi+utgR6Wgx*Y*2oGUf*jJG=x&Xu44NpiMJO+_1KX;P3`m+Jb6K@hQhY5 zSBVQH0+7|Q8w@6wfo1R2&ZIl;=9R(}<8^)I!viK?93LAMkWPxf5Lpma+I6~_-cBcD zH&7K&eK|dPC~vLvCHgo6j`~(TUk3?OI{`I_TQz(ECfxb4VaVMPZs#GiGbVjt#$d#% zxlwFJWUwPAyU~Dx*5;{M+ojiklt_@a#0a_nG=nARu!iOMWLuyZBnFdh4qs6{o7q9H z)o-Hiw=n=q!SlM;+)MwvgMIk$WIsAN8z9NDt3+b{;YOSmItIKq3jXP>DU%sK+FSnY z@7$m~9Z;Qb91ImLlFVw4Yjf@Aib8ss#L#o7&U9vClazu!HJpm!Q@iRl&!VS7Y3efi#_9`UHb}zm1|;8 zA9ciG+(n#mzV)*l(Ut54#(d@?-CDQV*Cy&GO+vAKB(6Or2k4KN6^q*F7*()51{j1= zVOsk0^3I0Ku5I*cLKs6^n6&w^dU@k+SxQke-A^It^%3&r3GF^{Ni(B1=!K95?$|x& zixg!2wCX*_kOmxahttdKyCu(8a;aKNT?Y@W*Es}FOW3@y56u$C5;MoX+msiHE||~M zdG^S$*iAObxXGYk{(KY;;_-DD^_c5qmw`n-GNi__5OJTPWfdWmfl=|VK3GrHRjQjT zQ9hAWyr}MnbfHkR(*BbY4=iu_8vu)uWhzLjWVPs7r}(DNDMdv@2{i9pMY!FHnK8I@8Bl5+i zEb(w(E+?~TgRD?$h}$H6+0ou+idxY(vpYf{NLpM)O7YrclJEDnH8cySNWm-H7Xk}B zB}LbFMx>qsWv(L-4B?ms%q-zGl@LhC8Lpumx4jq!7rbH(4U4|9`i0e9Jxog+7yM;74X@6WT1J*;S5{6U?T5AiTYJ(@kW=J}!Yk0~St5~SBEm1D5$jRfw6 zcRI2@ON*naLGV|w>a=fAMHec^^dW2t(g16Uvo1RQ$*M?4xx!ZhWEr`N6u5t7$K5e{ z9OLC0KEfsJ>AD-Jyi(-0E{dPZQ~&Msw6{{2WJ#N;wb$F|o@J9VBxbj1p`oo38J(YtKD^O3A4;dBOLGO&H?I!?(j3l#on;Z8lbO{yB` zt!?bsGv0$!J8f2mBh%LUM9o>(Iu3m52$66^vlLeaYCfPuXn`~Q?IhS?gcGXultBFIsHwlEoI&E zjFl5mU^5~isHlh;KTm;$ToklwXPG)P4%nU^;^6h0OK7(SlhmDJk*|%shH2AGe)ET& zWVaq$pw6=Qx`Cu;-{k2=D_a|`rjA;$U%C-k-1>DsPfKdoN7Mf>Gm3D^HCq0n zXtvRCBIUm33>7n_S8t1Twu1ae*fRktO?2z7<*2_yhTbcOh!N$wep;j z&gKxmS@rTa9bdJ_l)??~da zkH$~71O;0VyMg< zt7G8(zy^X1<2=}_PBZt#D z9dlh-!lwHb3v4A)?NrJGuAm7f#X>`K#5|_L`{rE^TKD_3%=njD1)&0?h3Km$tJFs! zE-qbj#9DCM=q6Pw&mH1MN)SpS^2c#!-~ZfDtJV30 zaUIkXJiLE!=2%AS&I(&To={0*(q09Coi)$)`m5yhUjAzSo2W|8om`=edsQSBDt8a* z;)U9m3NN`M%jf%)f3m?PmY)~HkF7-umF$=*pnEYwJgpsqbg^&>@yuBl!0am zKA)oV$Wmv$cKtvXg{CvKAw~5zKEslMi133RGZoIqRI4w+mWUjWBn=6}_fXnSZi6VQun732WZ{IBuH_3SKtr+ZHjlIiAh$Q&jSgV1*U2kuGNC3_s zn3H1~BN;8$8R;1CW5kDkS>rq_VsZxe{BQ@-%jhseH7<`x`U`s@0 zS9oF{SHvK(c@o~Ly2THPM9aU;{5;w$081MA)jyW?oqf2S-mn9WSs4}S7QY=rDRQ^J zgXy@rL!hBsulUy&h$QWsZ#%J1AYXj1Cv|zK#Iz}v^gIPh`1g7|;x_~1ddr4Sb(IHN zBv_?o(YcTL?uvcV8}rRp9D~o5Y4wQ(i)r;g(J7x4OzM{cgJt&KRX5eLFCx}GgBUOQ zZi!%Ctn4yKxdVYEMh;vzNhrha=3In5uJ=CtDHLgQ%La~5uPKkzDrcnWO|8Wf+JWNA zAilz26;kJX4S*nRSeq*pu3VUN^hIN4N2g%L3BMDvmn~MzL-5XcO{fUQqr|0aMXsnm zT>pe&uH0=HGrFL237KQ+R8OejYsfd{IZ=P)@B4s|%r2}h5a}`34%B=hhYL zJGBV)pi2DP>x_CBK!3HeNJHP;PvjMVaJGeP0*B&wFGL^RT4^g_A`^~(Jn$k$V7I>6 z6Mt2qu^PxHhuZ|Awd@7_jDnowx}f6-^I$YrkzOt8Af{SN{*q4Qv~xXeexjAcZMtFG z{zW2e`WTMVAFrF9I7uU}S&FU`adZUIsahIyutB-g8A;YI!U>PdUJKcpS^~a_sKr&kgM7D1PIkb<*!GT>kplPJ)>B(2V ziBa9(XK78|VV2uTS8R@M6$(sk$erXOOg?E9k#iCG6rTp89@a#XNQclv!l>d+5w;bs zUOAuOT0a*`8e!WOs?dG~Ga0RAai%Mi`iXu$kI%v#0CZanX`r|(RhKLi=_CMuy9h+d zwfH6o066>@0#o#JdAUNizee^z?5~42KIvCiGEaCc%unKNIsPd33pZS;>5AtE7?uQW zEQ-$n(S*l*4B0^nl1|O3cK&tn^lQU#Kgfr zM~uyoJdlk4j2WSJ8;W;^Ma{S+OIoGvT((yi{au0Cl}*4!H7r@5g6jE1_x5e@qTujF z_{9HV@2!IB%APmS1W2$DEVu@DcPGK!-7OH@-GjTkTW}2;JV=!VlY9HgjdvLn=da88f@NT0TaFNuDdp zta|W+7>@^b8z*VEU|I*oy=SASVVEk@s-wye#=a4oeuIlNB$077a!mzmDK*ssQCvT4c< zANKR%W@%q7Wyyi5yQjY~&79k1wgF>5M}={*AjT0IhDW;Mdy;+&CR)z$9Y>j-3oC?x_vL-ftaqCHk&msASV(+BCs)g0M-#x^1YI(|PPPUo-uw`zNqlJ4KY0daR zs}J%I>@rK5yKp`rJ*LR~=KsS8W3rHy>+*nH04P8rhypC=an9Z_B_8M+np<$x1LG3* zrVUOx;&#XUv4f;9E|+tSI*>q-+ax&@;V_TwaWLDpV7vz&d*phqpAp6BES#sD$0_1T zE3z8r&3X z40K)jB0#H3`^x_Nnb10w--1OqII|nQMPH3zf4u3<2Vaw9bk%I*C`?5PW=j=b%XF?F zhXJZ`M3lo&R>xKCbJWK?XA46y)M*BG^OSVsGLUY(M3cZ-I_oWnYxg>@c*Szv_|7 z=5xV*`=@+ur9j-V2xH>`1%XPX%Cd7G)$OM-cS)t!fUqD>^LM!n&@2)t@>!*^4NHRJ zI_+-iW;sp9a6J^LXR}L)Up@8Euf4TyNHd$a{2!URjX_gO+yiN^ANLWd|3Mo@ z_@$Q`67OJ&+Y&`vwIADZx+hKa%ld~o^$IhWfTab>ZgZ2^YkubQ&Iu#sQ? zhyWr-LLjQ)Kk5^J5~{XeFIwMH8hO@@kq;jca2y0`$etA?K|uh03Tf!-e8T_1&H+Xa z0)`;Y7F7L*FacHz9iRc;4JdXHeYct z0#qoV2|5<9aQ@K+5?DhdAYmXb5K{Gg1RWsE0JEjs`DYVaDE!DkUiok0(+S{{w*W8%(rETSU+xZIP3VAp{u41EY`*_Q{AY)G11k8R7XOmQe}>`@ z?d|^`Ljlr1rg~9AF~#<@V-YVXr^fg46@;t|rtA;=+$Jmm%-p6ZAu&D$9YP__7LkS9CYN1KYw1F5MPHHK89F}dO?W% z^RJB__<}v3oDAyErGM_J|LC=s%2|V+@cafZ2<%^9=qLO5{BQXGyXU_j`A=NWmE}L} z`fs27%SzOL#INOy!k7jFlg{9A_W|+VOMhAST*Cl8(Jo-zf~=N5S#aGd<%OYDP0>7urlTku9 zTyB?#om2gX`&bx|+gY#w2*P5O`p#y())j(9YoS5rN^*)}@BzPjTzy;82P2o*i^m0M?c8*I zwA|HteXI{Hh1Jtb>efhLMD+lgbzypf23c;j4yO*gJ*f|RY@t1o#n(hx-Xybvo||zN zuwjR2?s?g(gB|I5$~D@D4e1;0A4%{1?tjW32*SccFUR_W8fma=nr=?lNCNSA`5&C1 z*L&dcZqJ4d!4$SNgf3e_i!HX-SBod`cI=A=GUIez2&5L@XRO!!Ju9NIIi*Tw5Ka4K zHY#QY-WG*~rxWpk{r%U@kFQYx%WHM9Gh0ZQ%HOeE4K50g#|5)I=-+I9jVM@GHL`<5 zEQ0VPn#jir#;pjPIfY(3TzZd(TZi+k#|uCvC!7KuQ|;kP&{~f)I;}@X{VCFqFf_dV z?jklfqQ9m4*M~@m%!$QT&+GO_prOxVgCpAglEufov zHfp@o(Nd%J@se#nsY;1>GMn|-kD!X}!8EEIS&bswyRa~v)@jS7?;hu<`LG6W4zeXu ziJS@CkVyT^ad)L5pC71R2@nK* z!c3JdG@7X5ILsO(qNvDu>PLra^}wn0i)=y6=x=*9n>}!_sQl0_>NdzfMi)SNhsA&h zMg{+;2EX)Sg%XTS)sgyO>|1ey4xeCbP*?(a45>H*ZF;$Yl^wgnHbF8ZiFBD(6ODAA zuBljMHD)C!a4OdRZt2l-y`xZTK3#Ulfz0(UUQSh_G9{z2Jy zx5K&4fQS|Uu$b7w@qCdqRON2Lml5sHM&SdyLT;lFe}Gt>_SI~5!)2Y*h1(j96Q)b^ z2}`&_JK902=eTa|*PT9TFJxzW=OS7Zi58s)Wo?%u%l3~w{SqlK9-=Hjv(aXE+11f9 z=ZapxTOy5soo6&!Kp-!tiNkUC^UP*NMS*muHQ%mu9EoQ3kDuAL0XmD%ysfsM5Ejdg z9-C{tGak_I^?wAuw5K65gdv4BD>fT-sV25#cZ1MQESE?l;XZ<_Lt$kkeXjQ>{x6iY z^}525&p()VbB?4y{W0P)-w=We|1Z_K&==UOSr$#y{F9!Tg7Y@gX1u zB9m(EF=1F4!Pxt1ehfXKAA;|2Nme4CE3|byUha9cflpr)JsG$Xbt&slxW!7*uBDA+q%IVl?A&?p&k;FlL40HW})fxLt=~jrq(C78P&=UD@ z1VpvQ{gJKtT*g<-*G;3SDhQyB*LcGn_OS3f&CULu{Ky!<5?VH0>f?v7r( zbx-ZMgJGNO5Dv%3Zb{u85{J}!aykF*aGaiu300P7j~VHe2SL{%9Yygj$9*F>inkjS zZ*)8|knP6-`AQQz=_BEaMeq;N7|t4IGSq+FSOB53Cbu9E@P_Uw0CmMg>SYX#+E?3= zA>GZ_=F#}NYa8Sp<(M0I&+pmr5Bv8!UCT9vocjr;h zEi!&0l+FMx>Q3gXh41#9iADLP;!HBzM+=CP{#hw(!%{+@$wvbIbqm|CTO>5HIamqp zy;r^_*P`;54gGb%Rg;7wvuZ`>lGj`o!2j#1A`#nu$z5UZ>X35;%BRGkZw~_4-`8dJ zR$xjNYhBV`OV}5mEjJ$+zvv4>&rpyo^8&ZyJ=8=UhGD{i+T1e-$^3LtZj^6_P)mS zuCE!`r%FrYrz^JokN|!O5fsxMj4~f|Lyr9XLA>A~^tysyQS%v4U4mito!Z8?mk#-9 z(fAblqW)Iuq&38x8OH3+~3!5U__Gw#H!e<7N zj`#)ji^dn(e6&zg_dk4<(wabkOC%gI;&i%B+Oc?&4>!p7>;`;q9m2nE`TVN+#Tc!=ALY|dwT@ReFd z?)PU`>cIWJvgGG9eFH=(M?iBtn9YLIDbB}~e@Ce|;a(H3avfHhs@&z|tcq;wNl)4t zA323W6HlG2hDf=C{GK_iUgJ=FXkJ1u3NvvaHZ|AU3xQc&$jtBuVbN+Guvw=~6_fo- zhrHnby2HyS6NgXugg=OfhCpSq!73&GCIp%E`{|AFbHj|l4J{JJS8@%3@nuC}8}L}E z=7Pj+`KPp1ah?g#D#U97DmQ#Z$tFPft(^T4{Um#UBUkYA(OU3NGC0r*32(9$wBryD z_B0OIS_ppHJTq!Q056R1kSlZ&xR<()*~6FF6D3KEHH=Tt{Oq*yg78WD>lYCuzQGMm zvd=11>h-+MpVHI(*Juj__{?C1g!OR?t5Lp+X|4qIJ;8$ZL#y18Yf=Bye6KJF;74z) zzl7`P>+%e9h)gz+B1)n#Dp6$+8hSrlPd+fI_xQ{{Jy9G!@Ou+k393kPx|A_{^{1 z8LW+>@fEf=kSJKyIjV!n9NMe63Dz6x5 zk>(YX-24>-lf`Ii^vMR1^Bg%?#}FvOf7vq#WMD`udzu%?iY(D)wJ#8o3I1jFXFl@n zy^@o!D8T03z>F=nMZ`9&zV)UI`IldB^%@vRz+&CjUqwj-@yp%78Fs`%<{jZ(lLh<6p}+>f<#5o12foH2GG@UTO&r-TB^i zqz0Ap!}AqcVeuAe#@|8RenN}gOa1;8-+!KZ5aM5BwF|DaPBvtYZ+{(nrL1gd{;zG2 zPReJ0|CjRPY-~ptawHVIl!iW5|$^6qPo$3w%UPZzm!69 z1rjbu2XMUYEgHUJ0#&KO&ol=(k;H-ZktSq*+R$F7Y)m8zy z65i5ux~B#RcoLWJM~x2`{j^(+*hLZ6SLu)UzA50QXmHSO?56Qb3E%oJ6nLoKv=g1Spno}&9& z^HL`6mL6S6rP7&#tZsH>V`M5{5^cE;^7a8k6|t zj_gRHM0zFb?m|2&Z^#D8~{hM(Jd>g-LV&X+j@x91y+)gaPE;7IFs+ss>N% z@;8%8ryIxAnaq$0RvD;yZr?Z&y5Y0L+xE&M{azR-Hn0UV3@gd4=r*jn<9hr1T5;J7 z!A95Pr8(*p&4}Sd9ulj^EjZ6zo3Dkyz;EoMrnUCRE0Iyx6n=`gDt@=^SE7(E;CA_I z-0>TE+&|;ugY8vHy$F($m4u>F-|m(Yj~X6hwjiZP^x`L4aRj5@pVa2Q-j}m%_QN_v zCJ~Wg^Yo5#Z@DoV+o-o*yV0yAdj(~@np1*9__v8r5%fx-E+yE_bko_h>I}y@f~j#I zzNSlyrVtc!L}uWDW{c;`a=S&7N(MW%nxGR3MGudYSFZ3v)v6P^_}ju)q`pwzrV`!* zy_q(fyI5%l*JO_-l1k+7DvO{At@k*W=rC0+qDr(d`bPiioovgqeevv z$T@6^JZ7v`eKQUF^7TaOXWu3zx?iK=n{hJayT7mdziHq2zhG6r^RGS@YiE|{^qetO zz_n|LR461FO3%a6U2gZSsEeZ4qFKeYk}URI09Zn;#kf(+eOYar*9} zp}HPxwsW>~yR= zb$rFAKa?!hSMv840_^IIoTl)OS1=`MoWqL>3@w{^O=F60 zfkFoRlQzH>M(P>kG*lVxkCt&?c$!D{^~515ipEBaPy|RP*IR7{_qZtI1SVRqcbBRZ z*`-k^ioGK*lGtyar(UJDq$+&?Y1Gph%}pp`v8*_ivMB8OHZM$4dd}qYf1GaZEmJ^U zZfeO9`p~HypJ&&aNWWfz+bq^{n?H6Azyc!QoG$CGkC&D2C>KYdPmzo@a*VjfUAN;z zcm59|fD!;pph<18d!izO7fk}o;4ATo8c-?=K#$}1)JCGCm$|hllV`nBk~P#Ew2M%d9|yc18j+vVK1rOtrnr==!FG!NuF8pzobNjzpl zh8Cs3h(he+ysk5|(kW{C7`^QNtal5w%FTk+v_j#LmJyPij^8g=A+>2Wfv?`bSK!H| zcsdPGxj5HS+xi1Kw*ck)BS6QIKgUJYqKMD>((ZaO>Ho1LirLImWLmLg`;u?A0GvwR!M_e$rPLh(gvn>#_s z7Q_Yyu+SG++%O@$GQFAu$gbNIQ-$tNYl3L&O}KDPl>Hue2D&F^!0%``*_)c#P53t+`O=JjmG6O@uWO2i2wq62 zHhPHRUPY_kJ%JUQwlfSpEBWIy!4KepmJ+S z5$j&A-M&vQRcmCx??f%U-ka#yXq4Db0-w`gbTDsOtZ1*^kl3VudC+@FMLi{F#n4w> z(^z;HijKQkrnC5^16f=Jj?*40Jgg~k&Z{;wES%)ho*0TU9*_C+%H2dLg*0}t{H?$( z8OxDNEk&SU7pi8HfGWhFv(76An53--OZ?s7SZAkl#O! zho9P+yWHDVLV^@}9gMozxU#PkWY$|MS&kcTu<0w|@KE7+UY5o~7AW}p)b;w6AW;<@ z1Km=oH+73ZsaVEvQVXU&IG-5}=^T5A6Y6SQ_xQp5a!P_DR*hnYIn+$sUQh~LL@0f zmE#OjX7=iyDlHu|7~}T))q0PPk4<)AIF#`gUm?@!z_fDPik)pX2T~Z(B!bsVFsG1C zZ&%xfw_F)q-{Q#C{+RDG&#tmN*j~7Mygrkij-uoD;bOO0qbb2wtxDwv(MoVWI2_DO zR-Cv}g+5iZnzqf*>)&6p2J?B|L$!%W&ms}qZ1+Yzc2U*OzHy+cJ6`0}f8vTUuM<}) z$6Y9Ojl~pb31uB~RXwQyM5L+P4oR{^t$j{zPy%!V*mB3P5wI|;}|o-!C{Ef|7y z*FD#0X4)R)j=<|kPT?LOBlHvZtw(L3%MwkO>qWi4({2wXt#=Qqv)MOTO;V_o(^DX_ zq*iRB_7%xO{P9}=&HEZ~6OI(#Q7^SppzT3FYyWysG6ltdNN5jxDDq&TN!dOqh6NJM z9cG)B&SQmd;fit{&Al|!N?-p|(^4~?M>IbLBo%#|f>3kOq{35$H@!gzL|nlcqB8jP z=j9gbLA5z=o-(xKnQK_&roejhH~PXF*7*Y?Jx9#8tsZALt&0{iX36%uC6t|B!%g2W zT@V!!E>z+o)FWDGaq2)}2akfcj!RKC9cu7AGF#T~-(NlP-zM;jK@Jt+JgpnIEI7xe zv}iWyx~C>|mIwpKC1;&+P#!yc-z86wUzp!wzf4T1 zfR7Zj{S4IRVITp?(XwlfPF9yZ?BP;fxmFWPuRscT`0)E}T=RJo-|UOR+tHqnjn^Uw zye)x2tk66_6)`TIkwh$|iDmQC-NyUQjm2mc)h_C&9fd?@w9`vwX40_Qm@q+fPwKbAgxgi&of(%BDKi?T7lm@?@NS|w?g+19gFE;MQ;B$TJ@jv z#%iy?MSTPyf<>a$r`K@zYcs zm-J9-wNOOYppFnXgpoqC@^(j=<~TLCrtw#~gcxg>t^RA(b5tPXd^Fp_J%yCldW?h0 zGPS{8Z68Ok0E0Q&@l&2LU;L9?J1Z<&I;-v)sU+5Z?YlBh`G#;-;OH`uW1Om@l^99(%pt;PDPPOr;I}KKFoA5yMRBOoDTy@(~+Hze?cTG5@1}M z+XD{P(iCQbomV0`Oeo?BqenIGqA0PJNy9&yrfV{0z5|~hKA|7m6p%1m{w(u2URJ6` ziA=Gml6ikStsTc>MXS{;wn>ObZ`TXI!jslG5JDW&iWGCTS+tup0+q;?R09~-Kab}4^NYuxexJdp>GkogE4H| z6JiGJ+28X4;SttsQ$!h^O6L64qs1aV@-Pn$2ZrBT*I22&dXCJ^N7sqTO@U|&^SQOn zoRo!V+`JSm(GB3j=h;7$;(6kx8RG4 zbud6BV~<{oMdZ}^dhqdTeex6@k1Vc(VqQnL)J_0}^rn8+!);syh2!JrNkyJM;nZ+( ztZ1QK!o}$_?F0*sKgN3X7qjcTw+52s?A)Z#X~p7KC&bHk3Ji=K;uyuKyro4_o<>V; zG~kVwzNPZ|_i$|lUa^w9Ggse##tb$&VBom4f5_lUEEwRQ4m1DS>2fs%=Tnd{S0Y<` zp$v4kXT};TDCGJUk;G`gq-OB8TJPksPuzbG#?CdB!Tqt>v0tIwzfmP~tGg>tz}s>#SdEBA`k%ZRzf zSU+#^q|yrY^t>%DCdetIPwP#lrOepuu1i8h zU?ria5bdg&&)mp?ZY-)*ZCe2>9Z|?Vap?gOxQaX!i;9$ray&x29_4l>i3AhPb~lmM zZU#;jGFY>{1-fC$FSNY@xDqBbqE`zeBK{ z&c!5msQu|?FAPh^{|7jt?RM`je^El!_K4zkro}3LMvaKB9W6E>lYtKcL zwG|^MLQx&fDY9No5T&keS+VWzSPO#T6TSXkh9@J(o5BIQ{PtOPL+r*sn`af`;<}by z!#KErsA9sYfVdXRL~@35yaTSm4EA76400NMBX)7jc?O*Z>1h<~7%>*q^TYE*Uv z{*193WN(a1@g&r4(Q|s5`p_;Rb^BR72!(P_`)h&px7`kRy~ao-USjt@2vH^!08VEn zixX1OEY8*27IbrVtRFQu5f{6ey`y-f)nd``?+}y&Ix#ZLMHAVMa9DwRPce#R40OXX zvsxuh!XnCF3URJ!%qHi>wSSet9&U2pXF)Zu5#2$Kg|PCn)c|=1A(JGx=(MP#{Me%} z`{{3g->}}(&umaKBfLdHhe0S&yS1{ z+K!dV;Dm%z60#lDwS%J%_0enS?vl_5b!c~bKhcGaJ_rd!E}$l7Sf%0gkdM~&LJOBj zfLF%o_)~9sos+_7PvNc(R)sRx_G(H^opwv;Hw^zo00eW|8`NZI!o3a$i>CZS`KoOj zo!SLbNkXI0TzB88#nRASw1f(+2&5VU21U|b0rEprag<0lLkjsOjOgZG5{+hOA_HFD z`o`>dpKxQE#0#;-HX2QMi-qqNOAqmvd!GUq_DyNdcTD7UYkRoD89xH3jbWXR$^+Bg z*_+9g{*&BCzxJ%Q!~ zUEtGrm?8jPc6ysV{)rct&Fi?M?{eQds#!K{Nc(%B)TLE>i4 zick#im;5N}#7;A(bzSs~BhmxvEU}#}G)9Pd<*Q?=xw8gq^8O!c~SyY7x9V37Fb zThs4{{a|H<#=|1%_&;cRI^DIkeD@S*Evk2ax-n{2FWxGG+qVzlTW)iI%_tl(3g=hg zIjzHOL3KdFtd`kXwXu|OjWwyfKa9mwBk|kO;R9nq%;flltTJD%S!eUW{Km-rI)*JrZ2u zbIV=akVF0sMo_9Lfd2=7Gt~`qyvYp$h#(Aoxhh#5m2ct$X|-AmV*4PUmY9_Csy0K3 zN~X&WEj=G}-xXc70?@HA92UV-VfClDr~l!mGyHrYsz@ASdTCSW#Df}%Bm%)vW$p9K zdc3#TbR6$$+!h;oa5yo{t(hobb5<>UUze%1dYH97sRA#&5$eR$2Z9TS$D14$k-Hj8 zpi|L%;EDGX3K^`gsB-oZDxzi9*5O?pcVRHf<(M2O&k^=cPt>b*znSX`6NT^pFt$9t9rJqhMml~M?Ozo%ka2)3s(9QWU0Ok*2Gm}^>)X#BM>KN9r&OovV)|`pp zSz;DDFS?9xxtF@W#E}Z{nE2X0u}Zk)yxx#DT&1zn_Zj#J9kXmGCle7i`3%j4oNMbrCm@CFEBenvtg*VtlU)mqYO zHh&IjytG2r?0|q4@UP!R?3QYT$1sZp!Dg9+`nateOX}+iHrjUzf%ohk6LzVN7fGh9 z%um!P9U=*aGoYIA!AM7(m;noVY4t%(RxSpPo8;&{$+aFIeOYMlHN+ogi2a}>idsC)XarU z;~0LTNR?Al%xb}e*M&)ZF@u-j-mKghDr*x;QV@n7M|~b5gvfOgfkwNOTDhJzijilo zQ5~<5M<1*o{y&6(qo?K}5uFj6Ikm|H2oAPQ?C<$fisDi@d=)w8i@+GQ>_&ayP zz6Gz?fkv}S(lI83Q(M_Y+$A}E8(Cc3wp#Pe)K*AI3_r9wey9B87PC+RDs<~i;DD^qp+DNQrN z(p&-*Z(*)k)6hOPuq7DMJj&76dOnnDy^f=I(s|$Ak=o_|3ifn!tjcB)@|=qD(~wK6 znd)>59myYpftNZEI5zn8gQ6S{9#_vMi-l~Q8ph=eOskfWwg{#zX3Z2D`#`1B+C3sP zmSq|go-x9S$>_W~_ydnkO`33y=G4gy=0IkLq-V*l->+k~k-w8VB%zU*wAQgKkskST zfknES^qW(x$*=T#AuV(Gj_y|J2ySD3E=}PZ77YY|dR$8wP0P1e^qgTZVkv+oE{O<8 z^1=PDcGyh^*oL=g7pRB2ZkAY;rUsI-M(xcPyL}%AcFrYDr)OL28h&2l(kOiy{hMMS z0f4%U-s?Xy4fE%5niovSDX_ad`kBj)>I5ui)B{l>US>E3ory7#*|5c2b?$x3P>-b|pE>pPi2Ag|hHlijpeOnIBqb85M}RppEq?EFQS+x1YbvPkjq^~h27 z0D`M~&n4bTXR`f{xY9SvSieTx_WN$JSUOz*HSh$u+hS=UMga`@;>-EEIfvp2a0g?P zDLB(*$}#kR;u{X+z|)JVTl`{BUa{^?rrdiI#xGxt-c-%Kvao;qcXsnF=P??Mw#UN_ zqC(Wj#QWK5gZAzUpWCz5&@jJmo%Y@JG!Jd7on&t_il@toMPt#y?c8h(`<3#Yg20^5 z)fCrObA(wB7b>r}GibFM+;00Oole)XV@DYaghV8CIw z9E3-!b!H%%l|~{NJ+X))HEeKVxtsp;K#xMjbm6=0Hf<~D>bNTeq*d=#jMiDE2@fZl z#{RJzjnSYt_Wj2xiKGw|3Ij!28(9j=Z0BVax`PJ&<8;7nX}bhhF89-fhHdf$y>xM% zV|stXQ2;BcUNw_)L*q(FH2w!Oz=5FAWV79UP~E_eW_ZQp@NTu7t8nNxgscBwB&(BG z-uOz}B^vOjg7Ot_enW--qa1mE0`^1YpDe-Olr}y-B!DClIVhC%dnNu?R=0HDJW(j>_YY%?uLi__Xl;Xw*`^6tbry|s z-VmqA8-q#baSILF&g729B7u~m*`HDkO{Zxzn=tLAV>k>h(A;rgSMM{fF7deU+EzaT z)hLJ`hAPG~EN)5C(P{Kxi zcQp@?0#-kr#snXCg<(w-G57*xNx*M`T@hM1dps)E)umC=*}OXz7y$C3p@*!QR^DJV zB%w;9Ph9pkomYonZMNQEq^h3kdp=G&PC&iSWhBm3002II(G`GLv^E$+C5i-68exyV zNdOmTC7^pMF87ywK*}0<=q%H=R4s3jmAH%HvqD&zZCEiDX8mjpq^_6Cl?#@4WZ`kS ztOzl3IJ{oeT#H@qf3|M`3W$KzOaDtSxj$L86Grc@;?_7Msyl}`yK#Q7b}_GF+xC&F z-SK?tMA}wS$)lyF>v=q3P=gi!kM^YEQ|-Z-8vUL=)oOVnaii!+{&0d3`xE>I+>pyo zhp^;W&+RmT_(Loa*(PfvhmLf@=?&oahqIvZ-Re)Ko^oBs9WpO~Q(7B|>`tw{RXPKe zxJ9-ik~XBaiK=DxfCM}9Rz3}O#NSowfS|mY#n5Xf;~PyCzok9R%cT`24YwWynCl-{DYMiGfj(83`hXyhO4 zhsq_}J4D;yAvl;!FdZJ}0M=B0^P@xY9oz%HH@D!~pb`AEPH)_v23<{dh`mT0fnh-Y zpcoLk)!2T*M*3I;kSU85Y7YRGPE#Uy&TUlN57lacT{?E=Pc7%t{(xhw2;IqIpuLqfn|X`=)pn(rNG^+A;>8gs*J zb{irqN<_&b9uMV#dF&IcrdtD}xl<*7#}a_m_WD(lyXEG?@{Y9pY@?UF$Z_W@)*d#H zDrs@KJdNpz&uMVqemj*(HLR4B1s^Pf`YG%FnDLW<1whV}K*QX#9;#0R8zePbbxIZW zA_*O`S7w>b>4`YJ$)3G7l7W$~3+5ZS_ns%SY`OW@i)DE`y!%m-CyEZmf{swLQnmqq8i=H*eD;)! zk1PyN*U~F>yhXxYU!86LELIPmLT23-k?$9a!>Sei7RBpQM?e!eeb>1Sxaa^_GymOm zJA+hO^d3Pckw?DMF!OwUb=j&^-UMt;_~sj zqpP`idIu)+@&ZFnd@sDbZKqIPfSRY|godM_R;ZE3)pMy;|5yuxSTZ`-IjwPyZAuR1 zc7H4APLTAV6{5)3lOWC_Ki4gT_VF1Eo|z=FnO^AoL>-Rc-I5q}8qhMOS~ zq*Y7Nav`Ng&8~IYZ(>FYpN68lvfU9CvJ6DA=astJ(G0G#1?yo@@*6A`Tf!QO&^~-T z#r+uI)@sJ?c%O@twxBfO^IAfJR;9&lJA!wGP692HxxW~moxKL!x6S)SfD+u`ypQTb zSNrp%12$pZK)+T97%&sLDhG8&=q%3|q&*e1ZnHfidXjBG&P<_|+8WYlQ58gINT>YWoV4t}xm zVA#LV_C^#SW!4xb-AE+fDlW?sBEIA}8Qy1Ii{^{z?aTZ;m}~~%Y-{z0&RSK9Y^Z8;(CbuZ8 zxt+CgqtZgE2}lv@63%=RMid&djSO~|gL}h?)Hp@?#vX|j4yy%~8y)|(1W683h0;8V z;Hj-3(U>hwo`h*B3gUMb0|yf5bUT{~T4S$w?^G_0c9`9EHrNqAOP9aJ*<|vz$m)>y zNI8Mhw6Ll>>fl{0zNN=x6#WXr(Wp;K^;pCeh8yvCIh_(m88mXMEk&iiW}cJSfj5m> zx3?r&U#xfsVA8AF*fG4u3=P4pw*bTa2gby=l%E=Le$AMMCQZS&32Io&U*!sh(jRk0 z2_Gm4n2Kcet?F>sm}%TjQpOx70w+^gFg6jQDJFsNKeU9#l(_PH%zPLoN&vxhmbB## zuMomYgsI>>lkrTpmV2FtRv5#CO|`}wbHuGQvEa|tC%&zZN>}5b3C@p|OIecl-yxQ` zWc_R;!%2k1qcf_xm4RdwK<`?$@k3)Q3Mi>VAIweDl1Qd2Ks6fOD||W^LpHH#j6hvcp=dW z!Y#l(mHEPBK8p>jK~Ne3Qd5T@xhhf&Mjy~c(kvlpB!kW#)NYXwpiw=LoVI75KA#Q? znjP`T5soXrHHmG+j@I8?O_S#7=2K;*)RZn^RWhGb*|TZ1mf$iM{Oq_WxwmN;nV?`V z-m|lp;|~W=MRV@}DrVp0R~lt}`}3%D)D{dE$DJ?4P*LZZmHKqv)Z(M|4^As>ZI${0 zBS1!I;ktULag^%%hf4`pMWgQh71_CV6T2Qg9(^Sp?j~-Bx$6~_=G)Us%$$@~c$aUA zi^sjE`T$EYx>2LYXfVL;xNu^#VmH(7TMQCn z3}j3fI%#a7mVJMjW|0JtL7+hjJk;Y7f@J_h*M0DKNcu(mhA3sSILdgrQRP)cv2 zP$ILcFGlQh_0p>)g}ui!P#T)zTT zM{mmtdam#gHgQQ6itZ!%MSLFUKWX3}_wS(WU{$dxW3#;f)^t>+ zFj&nNFQbSO#xnv?j7Q~Ej>rHgm5bdrTU%Fu__P(&kv%>)m~uiZr)y#gq$F-IlWDnC z?~VARQ+R9?3X3vU7ADq7rIYQR%;ux>ghGr|M$I2oNwY9@7d^UrWoMM|_d{s?`j2IrJO7JO zj?6P?X{s0_i3BOEcF5+rqNBRw1Yc(LTE0?Q*zYBG_D>q$2GQ*cr^`wgF4M@}{qH7= zHix1JHI3&?S?U~Ogla&eCNQz*7(l?r`QpKHLPUj1018~8$C+&5Y#Lvr2e z@@bR#yMPSO`1C$)-GQui*2fFp?JFFvdVoARLmFy~ellS;nJrx~tTs_fDnI*Z6!U5n zaR-1yUb(=!kb%CG8?7)?AffeX^CiJPn0~V$jzciJx+!rE$^W)bCVYGL4q~X<9*1cK zATk5JPYVDxt%YXQ8((Te|FJM#!|`EGO8bUJ#iF%q9@&mB3~$5Zb*EqA(s$gu@qyy@ zTYc|;{A^WsI1a|+!Wit;l}8+LqJM`9gA{(m^icEKK??I{<^{-QR_-*go$2idGT&@F z-_TG-pm^uX-DMrIw8G^|W4GMi>&28pTw`iTWE6_jepdvZr9wu_oLL@x_hYGx9WrJI zDa!IakpDIr&yC6|<2>L4Y=vfdS`&R~b`jO}(c}FNbar0FrX^oOS>HOwt26(M)q{m> zV${uUsSFYa3I{B%uL4ooGd!gO!i|Scj5^IO02a%vR@+8~+M}85xLaj)_Uo zA=Rajanf@&zjioO=&2o5u0&ESwimB#VYND#u_0cHFU~1iC^_8V;e)&77o}lnjU=&? zSoX+UauHXh9Y_rp#-=)+se|`WISm9?)@d>tnjU$KjpBBgr}n2z>tk&TLzq4NfNZGy zxlp&~N=#Ggo{~y$jmZNvlp&qM3xBLDVX`{zdQ*{5Wk`Fwl|taNXvspzb^*@@Mgu1b zUZ(VJCiR5}Re*Fl(Hh^NvCN~q;X`e+i(XymN&Ae&ah<7^i)3LxIbo}6J>3N>d(xMN z$+zw<Mr#oX*N)uq>@iHxyRfQoI+vhD3OGEazVkTUqm?ZG?W~F%GhS23l zgxWfw=U~1X2Xbhj2xh{=Qln0;X#D5*J?#l^R~p^)w=Xbyh!=D^`@z}pLrA&H@c*TR zH);yXhZXyN{(oJNahneNt*Xs$gYOmD1QA<&u_RKzYfh%#yT7Gc`Nj^2g0TSUq%GQ? zGXbC2x~Ai$f4%NAMRL`c7cd-L8~7y5pOoANlhgn5vUOC-ii{RvL(6P_!KHHg zoOZ16i$|%gdq*TXon9Fe>4OKSYuPP3RvbhxVouu>ZNh9j=COypx+d4uUBRs$h1a;N z9WY2YB33$iC+PmD$S6>k#wWD&tSclOh_h+==3LgpH1fXWL}PA=q>=QG{GDzovFI{& zE$_3yk2#`JCiwiJ{x;TkhzCne%-{V50xO)}Z@wLxlBubYRA%#hyZ2^D8q!dy^^f_R zOx)l(`^b>4prAnaBu^#&m*Al90ft*hSeTUCW$?-rnl~nvO`tsQ3PpvKH$6z;zk$&; zaf#9t0=LW;8hGkpc?G;n0b-YHWggv~5gFYqE*mouGAOdi<3&(v0iBbNy|GeUl>D6i zKVylKwo0xp|6m0`zgdrw)Ba-rjBq9)NIqB#xo z+qDE3T$*usPMt&fu~%|mgXXJFFBI#BQh?Iv;zBKY1RmvK6=?uZj7csa^pm33zb8eT zUvD`_0fzSv7iRyuX`zs-QOf$NlP>Dn@N`rVg9_2&<+PO3=r^7LMM}9kfvrgPM!oz| z1)x1BpF=qRjXZAS{$U)N6RE?`I#@ z6-7TFF`Ia2%ays&>$}r46QauNY_>ka#-nlBE>QbB=UBY&o{c32-DclhT{xC+~0jKfkCLZjN75hHN?(?g`H<{t>yXIVQS zGz(SgGY$FSBxY`-DCJ?Wj`3M8*4XA-JljaNil3t|Sdq^Q=C)H}V4l#$({k0aP`fV{ zbDwxrWKfm5Xhm7fmau<~Eh?e_0X#Jd#~5=+v+%v^W<^f)13I&TR38+yH`n`4TG{#& zn&w-tF2u7DL5LH-c+R!=*YBxA@g21#N3NOL?M*AGTbN)yEP&W1Yiy`fgmDY9FP^14jgbpS3F z)gTEC$65FE>E-w+@k0i}9$hF;DN&D7vvD+Hp(vL#?^^{n-hT%gTJkF6fFg&Q1d7gZ zBS1ktSFkmBvk^=Q3I~<`I;d`PRRc7eD!=99U^(Na%ul>zl-aA_Wyco{43$^cTEgk` zDFt<6qp$mj5eJAT(7A0090j8D&?Udc7r}<~TZFVjJcxCCD%E7=1}iMYdN5#rMRsHJ zW@dC%#W2z2idPv6`>Om?tX#36B)+Xube(w0m~*(z3SR*uANB+Ilsm}g^hlEv^F+COvT0=V2*w=2ReLHQT;=>%cDGVrj9H5j4zJW?8F|e|!=|*tKU|-k4{~nA4ltPnZvHor zdcfm(eCAS+9UI3(vWDorP$r;*7x3AOK%aph+u<0e4?7FdzUU7$cW$~u8JFqg zuZGHJ|K9iDO+UH-LaF(g=Y}8!6;8J;)E3eReG5t%`n{0|nvIAY%7JR(*MORrHXCBt zk;ZQS5zJJgv(B|cBQqE|D^3YiAmgkbzbVa5Th9HN79Knjs~SdOC#~Gd+;iSaMIJmq zJ=mZZ{ThrKD)C)5Y+VAM1Uz}vs?^4Oyrp|+)Y{Mkvu4)G-WLAdF9Fmifj6*tZWRHT z^EPpw5BTB;P0bwIz-@!7Tnc_ zwX}GNW~)u`f^M9fi~6DA75b%$Ng;r5wzE3w`7MgaO{>rt)zED?Sr4f*op~Q@*%aB^ z+l;Vu*Nu9+*JnK`N6GlDt){2+k9ABd(~KfyOxu#4nXb-zu6)f^-n>(QxC5KWak zs`)`BR<6_}4khwAZ|X?Ffs5S#MI84N?X5`&-C;PsLes-8=|6Xj7TauWq!JlkH;6W#aV2YZPhB?`}{C6=xYYU zlXx{{GlK| zai0o3_4y{yxbRc9PM`-nZU1uD7^Q4@n2euQba6#c7&8}z z^4(T$5L+qye50>YR13K8o6s#*G0v~iGF2T8m+>bhOzRE(28!4J^zTZhJmTJ9NOLHl z#j;Rlw)3rf^D=R${mlO&byDNItqRViUPJ|)jAVo#Oa3*j$=Yv=km$ootgyj3SZ(&L zx4P(di8bQESF=`h&)il^!Vbk^v+FZ>RERV1MaZYF7#3tUk%=Ysw8#o&7Dkc9mz)SZ z=imskK%vcFdES)`ceY_AkzP;5CZ>^Sy~_C6(+NNHMutcA1_cgv+^+Aa-**!`OVQ*B zT5ATerrQ}$Q!Z*Z$2f7>JT^>0sG*U%2FC8vzA<%z;QKD*QRMhx5zlp{1{=SM;;@fT zJRbE~lrwF@rXO-7jYqq}g0HX93mFeR_Dy=kOM^e*;f?}NRo##i=lV>_lxN>x*oG{X z{Y~L|E}no(F;UlZQ(}2w!zZHHX6Uam=nzoB7M4*(AZE6}@X6B}-k2Z+mgZ=NCliLf z-~6PW9frSUo?Aqj!1awiMAqWbVDfa?p~HY8WQDR(`$=!W_Z-Aj0Kb|3Jy8i`i*Y95 zne%xbk{0NGHU3#AfHEq;3F>k4Ur2(N{{JC)cjy57zg_@<*TU%Cd#OF3T!cK|@>j;p z=nh0|3|-Yoq4xcA4J%eCh_X^2O}%^AqkDej#t{{3cwhzf8p z#R5L?nB}s$F{z~i^DChq8yntjc@h_V5u~A z^FNS|i+bR~*zmfGi~~0X(No=kwW)up{hV);I#tvd3N@|zbMF3FW~_8=?d5iprhDOJ_k zBn_KlerUM_oK#m@y@mArR{eZ$e=-7hHdO*JzbHnyznjPe*rjFwYEdjA$`IG=9(Z4` z>nULTB3J)+155b5^NYt#`PbJ2XbkyCZC@0F#-D1~{my*yis)@Jp!OL1UJ*8onH|x` zZ2F<{tjQZa3xi%N454~wfy?&vncMt*xI1D?iee7;^L+XRj&i*Xr|0>`Yz1932@m-% zWtZS4G{qnghmC;q*r#3X9(qB6vd!+)IC2_K4JxAD@n~UXO;AHCpNT1WvoDl*mj@Uw zY;y!f8;&&)EDq8iyE2lSB9~^I8{z{hMmVVjXutCsF!cS%|H5hsHn~z39@oGh`u4+m zxVHwF`UWDMJN(^v%X&2)lq*^+I+(;>P4^`P5nu?)`wr!@iz5xInywDuVm#ur`2E)sJFl-Txn8ULuOD6cH-=8kIda;&s}eSSO3f?< z6Ma$aEW6=BX*{vvL}t^N^HJhkMI=p_d7Fw;zc^aQp40DTuGRw&qN?uq?@57vhi|du zKDLV)k`RNgot-Z^X`vJOy~`Sq+@`hOH#%)mq2BBMte(ntmkU<(G}WjAMs|p2lg${p z&TzRFKj4`F2Zd;2bp2lsD;EOkq!Gh$PRl$p_O>^0g{AfZ`&f-MSG$AJO7$2BDFg)aj~|a-Y2l`)6bl(lrw;u!4*?PL0;Bkdx=)% z%T0nMGf&Tf*c&7`(+eOMN%uC;Y+sBULL-L}k5vA&-lpse&|g%z z&aB@({Nhbfbc{$dQ9l4iCB<~18W{-!nZ9bpAv?FVV;ANsW-5v^Bt%x~|DOdf* zI2a`!yBCwF>W}DTJ3UdbXz+I2>!BTu2C9! z7b7Om68QK*>tMe6_~$S5@#kwe01D~fs4JKWG<5Y>FI?-x=geZ-S`+1w{HF`lAaf`< zk!rr3LTvzxJut%|6lYVCWxWrUXn0=P>b&O13?sbVSL%CinDv8?9W5&FShv%tiE#0mEE!a{{>E=;8WQu8tDxl zhrkiWJfhZ?4|oSaeI_+9bibrOlMn(}Ml^YrUv^&r=mVo^!7*UzVjah?Z2tI{rBq(^ zR*Uu%QZNP3v4tmaeW=UN{h+1qc9gY0Q_^_$q1mI=_QhjJ+LqO0VEO8ZV0&NzUa(-R zM6ouBZakE4WUFyKSuws$nR?5YZg#aiC&&`2%jUg9EkL31>twE}6g+jXb4oAUe3-^9 zra)Ao7BLyC>9X-9BJhJUHnlX~O)K_$?^g}Rbat~<_m?ez(whs*%k~|G;IHeTfKh*& z=D%!;oxQEZSyyNlU8qr7xCMDx8Wq%`XNkE)P6yo(+6EFFfr`dYO8oWvZq1Fv!eh4B ziYuC05$EeN2v%P5{V&>ScWac5v5bbp`h$Q&MyT^@LMVf(5t{vST_;lNl-zvkv!7iT z$m9~-tYGmxkGZQmBiEKjg_Tv?W#nFW3iesH_T@c#qgZ;?#u5h4Ywc(qs zG0*PHV#A9$o;m#}KlwfU>%kh5Nq?KWsA72&boZ%=-rT)bOR+MCh7`oAte+^jM|C(=pvKe`ZxnsY_PmX(J{ z9GWvx)-%G$dvynt+??9YdUXDRVTv!Huc?7Hg?EDl)qpBXja(@A`*^C4c5#uz+b1JP zGSLc?PJWnlPzOBbSw1qT@cu-r&UNWuWY_o-(OJmdB^|*m20_}_Mcok&yDq0}gtrHlkmStqGi`Vj z5T=q#-RaqVak&W^U+T$hX7*XX>02yY(@k>si>(n-f=cIoKsPG~r@Y~@8>-VK&O77E zR6ig`Y1Sk-;K%t@dJ)}OUfeG#*N#|B3P|EVdePzkEXr5!{Px3C^5vG&-vQiZ(Qk+< z_*&(m03$y(QxpoUE8n3FPr&>U!<)`9@V(wry4wcn2SGMs!U=_PF5-^6c;Efkznd|x zjX1N`qfG_&(@XYx!WaHNpL@=bt%yZ$S|=)gvf5rLIX74@^w3hxmYec4!!ue|tZ~?C z_h=yrTk)Ds$|~A8Ld=wiN~Y`VcjiBCGP3P?#M+?=s!A2vPFC&%YI<@qPV0KqxY(F4 zT5YjekH_wpznCWB3lT|R_Hqzn(050l!^7}`2NBijv2CS~_X?G>Qz;SCN(l;Wkdgd(>sQh5w{&jM;b)`M1}CdsSft;2FSOBl zR>*|IZaBCBh%3JK`trK`O^E)BYtk*%{+#9Fn3w~2tAsw>;0g+g8@FFQG28>{hMHe@ zmjV919igEUI)9P)4_H46kDw)dF)=M?p_um}OxWweN#!!)0E9u5hs=P5Q=|bGmV0xN z+o;re`Oq7}wG1(>Dyc`hb?7gPzh^&5>Z5Mo*oS<5!JYSf* zx1R(;h}e&c)-1p?$EPnmT9g(g^5$@5u@nbe(Ba=#o;MZ(Z=M}mCIqb6q&dnUZ|)H9 z&pW*9dGCylLiXW3T`dZH2Ba@tkU{tNuBW083kz9Uc;5@R9HFEB`c`VpA@v}CrR+TM zJ~Ui;e>Wr?7&P6s4B$9zAigc>aGPuKqx1U<8GF|43)x?mO02N=@gIyLIcBJ6Ul8#1 zdAObJg#Rq5%xEx_LjSIgYke0Uv`tH&R}62rFMFt4?MI>3)oeBNM~yRBgg6Fa6uF2H z?{;)7jZ`e}YX61H`QVf>KUSAkiSl&*&KBt!1)n?X%hbTJ&$k#s-I&1n+^PIIVbI`l ziKu6K<9TtrS)bzday`~2ks={K3sUt?2tI1{K2P*?0ik8YlA`0t_Zs7wukHVSnJsK} zkSRxz0FGi}X+N+>3OC;**u2oenSb3gwM=9!vj2Ij`e4u@4Tm)p7-T3O@A2rxx1*V2 z{SJ>hq6HX=t3LTeQdH-@%v)7>Kw?r_|D@F{5rtiqh>r9J%}2jR5l0SY=&ZI-Of zm16~cmTEDiLySkOh*k@k%ncv%F5<(F{d|Vt=giH1oE22!Qd6#2jCk>kMz&WW1hw82 z5)KO(?~!nBseY)4z*k*>BEQ#|WtY~6XEr8It&zRAFSw!S;$bDq)p{d#=R@1myJLX&yI>+B znXzFgwoiQKl-#}pbhJ+QltAAGugg~pP|W)H0|s}0^C-Z@2sdT}k)Kdn21g|s;yL7t zIyA5)eSU-3&ZvTyEi+q>?$3phdI9c)M*s`0N(lID=(gzuT`0K8^X ztDN`kR-)=C4ro>b1XGm6!uvOTXtHof%rSjPXH{kAGCkfL*!|G3ta6N%+tFW-vXsFr ze?ubY0Ae9=+h;p=gd_`crvvlxGp`y1O!fiIngh8(#bSUcg5&#B|ikIlDlv0NbocunM@`pYGF zbghboq+6^s-CcP~=|>ob*;uv{8$u#|?j*%ze1}d*zx?!@VYUMEdT5YZ9_x>m{2*9`!+i68-qS+NCJjnu@{?{} zI0sZ7G}eP@vIakvHv%wN zYx8jJJ5jpfnp(y9sZwq{`Q_z7GBI(3!5D(lnP}NfqLV0khl?fzx$W9tI7>R)M)x!H& zY&G@u-S>SKk7|VZp3RU53m|Jh)YxnTltDYoX$985;543}p}JcMxGHSMh)0rv*ofx2 z6*`M;BGc}NZE}+0M%m7ro1f_2-mLgWk^8 zKd}(%Q7E6-{A|$u9oBjXi+%0uzh=hGBAdHCJsA45)gU&VLp=&`)bZ3Tom&J6iMfBo zM2JPMz8*15g22mje!65DyF2@jqx(?Y1X}TU7HSk_ZA1kPa>JOp6%b=2=gkrO(>S}D zPyj#7La~oo<}gKk%ZTkihMALZRt)gXjlN$e%)m)@;j&I*-?WTj>SkUz>=CEEGrUK= zi74cDM&gb`=Mnn=7IxIPlF^=DIV`jIrU(sYpMpPlrm%TGxdP%_9fexGN}Z{N@YIW}~eyy_~XRjaEC6_+WiEq0U&+0LQBvdWj>X&qqWN&smr zpcC$T04-HnxItW1{<0pqjQmKs^(ZgGWE(r{_D+9c_gsg3+_eNZrgG?0;{Ed~ zD_+9S7Txa5=I^14UqN4E%k|eC$;tpoV{yCeqrpN2?zButO+5#PfB^iA%I)&zxQ2)* zkpa+B(L7N#YDVEG#%vURYq&7zPJ6jtuG85iGdug8{h`P$Ykc#6*kq70(5pJZ{x+?x}WuH*GAakIconq6*>Z`Wx=wy)wUZzHic zySn<|Y_1q8-6=j8Z!zFBuGQg_I=+AVHL7pj;2#XX^NZmr*P9&jDxQ-a(rP^n`d!ZJ z5lHI#$ofc{GQHN=ddOa)G(8MlNCI7O!l!CN${kLrR>sV4d@>9_j%dtopQ~$CZzT8j z6ZaF3_wPbCW!0Y!W{V-kY5T15>h>2h@7`(4rQyDv;&LS1;eP}3$w&%j+Cbp@%zmz4 z;a^7U`Gg)#1U(;M%Op!dR<$&OQF$Ylrt4_UP0D9UJsp>wFnHq~q{%mL5q@Swe0ZF} z87pmED)JlguERnB_DXi0GgDtDdevD?YD@_gD!B2SMDV+pWt`1;*POXY1``4QcPsLf zks9RgP%>G-T=w)Qlm_8_?3?9(@Ux5y7=K_?98C{G_K87mS|bhCDvF{8CN%xgj$r%| z(r-SZm7(f{<~}~}5Pkxm`**MxW<<*^uFM9dv$fjz+1$YD8=k^M{Jw z*hzbT8mC2ds9Y4&`z}#nX=q7WuDUXjmSY$5QvIr3b!I%$sukG2T2@(`9M)L-Hc{ah z@L>iJ-d}8ru2e(4hX7pUmt`9Fcn4?x;z-8C?F(TBu=lg%};`bAXbBzX%`x)saKwi1LI=Tk4 zu1G7(ByQ%laaeD3>!!)(0s&~+^~beUbKNUWejMyqM8&;1rEQXf5wNzU4Z^Nku{g?3zUf7G#!~GB6xvd%ipj zVx`qnYPdaJ``j!o6YPAaH{S`FUky8IC=IQ%mDBZ}xj07URdXJe@GljMj-Q)(eDdAo zeI7TYVIYu7br=-<{9P*UtYKag#CtzU&9d0jnj!oV9BYLM(@1jmBqcY>Tx;_3+*lId z^uzlPuc29|IJ%pZ{;%+=PhLNNzAnA(LE}k2->cS5#R1_zh@W(`=fBN)`bKB@{MZ_f zUC=Ir7#tid79^IEIKRTtg*|NEHrbbK)}U?J%3(jXOtGJq#%Q_P1BKKxNQJvja;IEc zWX_7fzCxS0#L8MpU+bhQK0X@+cL#`Wcv4Cz>%!TE z?f9}oacK(7>U68im6B=gY}>=Ru$}${Q z=RRMl#!7QZ%G>hGiBz`edue-ekh$LYp%_(lABoCiG<0ni~WArb7rLN0*E|-BToPJG@X&i|L64U;mhIk zN`7Fc8Da|vI6^A9@4 zTzj1yU+9W8nPMzOia5@&NWAbzxdIHC*Yu6xbeBRwLQsS8`ZaBU%sGg?9p9rnUMF5u zQc$p92?f_Ln;gd8*CCB9$TUBipv~dHTw?yHkQyzmpIf3)YI|CDwN;3wV-DN>wtq@- zig~X}yYzo~;%DdUk%>J##Kk#!e($+#oL`pOY~--r`EX>4b2sE$Z`X}q1cxQ7+xC#D zZzLJ2p*t(Do@5bEt?b_M28$jdZ4b#k z+7ERj^=fr)cL@L+U`k8SC+I8Vx$5-ohHT zDrO(t^#eO1a7lkS&bZKy8*7U(gb7yvDE=Zm~`vnBMIDItCoWTBbWFRFo z7*4&{c6WSwN|S~m+ar;nd+R4vVMiX&5~U#`e5f2QuJ zra5iNQ1IQZX{PgB&B)=LO|mr4ip7z~DOFlRY85q~DxPj2rt#`eE@SKSi`5{4%bF*g zRrdgV78OsQn4=JM$=Rdi)3)MHv_cOceF?2gF&eDWU4)Tub8u}o@ZnQZ>0U~N;bcjm zXr!{8mqJ%lS#0wht`yqx+J|TlODg?u-mK^b;13TEo1a5@o^M;jd8aaADn5s?P3@RQ z*MssU7y~rjo?=Ja&&MKJ@RKW|0z-J87B+RTxhT?vAojAwGQ|j13*3&$S02r$L@(cqXec$+?WW*C zXDuR!@;*ZhfoX9i3Ojyg8X~cZA6QH7jbb8%^VM%uaQn%QiGj zx7t`^9Iv_uIU9_yE2>$bXnJl?n3I`HHIiH%J8e_5hPrNs3NW;~-r%_muDLsasmnTA z!+C55Cl2jIgB`>Z@fyt~uBOtQ0EgM%o}pd}-R&nEoj}bHxCOqPW^~QvH7{}f;y5~1 zttsV_Zzzvy7$zb0ytPSX(I%4lcQjDK?sVz8WK)XmI6+r}5hE=zLxw8S_Rx3?=S0HQng-dMX@w9_emk7=?MM)NFQ?Pxw;dV&KdstFR#N zx`mEvZ7<-GDE%R>;dL&GDtynLt4_np1S^x;&USm89Is7(e~V9CN!lCu?}@Lz5?QO_GlcpVx|6q`>!|4f%0>w=i}j zsme=(gv~SZ6kS)9b>YPg<=XZAtf)%*Jn97%eR@i>vBWV1?sT(H2~USti`C!ilUiN> z?5BoOE{TyvQ!fX%yj&mjJ8^!#m~dB3i)WlYYF<9MS70>e{JU%64d>^;aZ4DlB-xAc z6NTec4IFhaHlm(9yjb4$;4q#YR#CaqNS4ZV6(u$uwrykJu!0%*;x~`92wl)!Ii>sK z(3vHO$d1B&t%pq7D=$Bg$X!%~vg&Hdzm}Pw&Y;V;>b*eF)}8)>jyr5za8RUOf8=nR zDWc0hdpqR&!IS&!O>e-esimd}IQhhLrIMyHW#r+M6>{XVK;$4W*}LW!j0JL=3PeN! z>#`dc6c;ydpETEjJs~+!c}+*gCXFiwN7>F7xbc;71c)<5zXJULTQ?fG*TGSo%eH# z$9N$^j*|4rbI4bdhN;H+>BvfrFZA%BmggnY@|k+bU%`*e%Llovs}nz%KXaQzWE#rL zyHxk`0QFEnx=WA_u6*gIlcG!7-%{a~T%W6%L9Yb4uG4H6_?~Er9kjK49WSt}_LtBZn?OlVu+^G2AuhG%V zf|o6C;cyRw5RdQD5b1uN=QCn1%$XdWX7rF9Wx-fk)JfD&mMx5fJ7m|9UA(ZXO956r zv;4BD*+!;=62Fd{uKW-6D~hJ~4uMj$xs&5?fG^4H1{}`o_Rx*G33221v{!gHIlt;q z?6O0%Y@R+)x6k|HHZVx0l7K#a+n3G?(E&5h&-ltpl%`y4&Xni5K^NmSmw3uS`p+z9 z1N6|9=I5nD64~O%Bjd3?=Dn#1fqXh*G;Ga_+VUM4!?fr1_GkTC_Hv!lmz?k+W8U${ z+v@Q&*J}(3uDApqOH|(DnBnnU_~!M>{BIuSTvvOrexuW2y#(Z(Q9 zvACSP)E!2ubt{Xv1lMyXnQ37oJj{LPX!xa&9~NVi0LT1N_p_mf!~W(tckyE=i_*)Y z3~CSJZ9;UKp9t~d;V-Doy)TA)WMoQWHn*SdUE;SAd#Rp3;kfc8gHQbxCzG|@r_-`DQiiD+#fWia@g-cr05SWdFrB1r|?bc99KtT4Y@q7AE*+NpS}B;KtY6yBmWqYbJch84j0DZCE(Xn549NdoXwN9nvaz&^ERE7A9cw`GF zb^DAcvndEV+Hw3QKKTk+TtBYj&ui?4Y?st->+55sIowZl#wX!DVKVB>xZjj8W@^4? zY984l-+EZH(x{bAg)}B_VXmK}d z(0x8sW|`Sr{LC-!HU{+C9~StzwEc<85EP9uzMcq=%P&2jTxRi;Js*lD4*9yYT;R-|4G~jJ&CJvlVzC%z3xs0 z_PjShD49;_W8R`0y1!Qz0`-#i6fwwS;v=RyY!hb4RwO*Z-jA%#t>qJtzRlEShqnVg2{qYaj^5P0TZK&J+)ktC!dXu8WPDtVn)NF0QA0rA%O+1z z4EN~UMmT!nI?lU<%R9vv-0NFip-gD8QaXv$igP{o>bxbg!|Y0qzy78xrE%{6z%H-Q z5Y7aQq5xFm21{6?NL()+>?Ija!hK_=$Dpw?pVb>YdI`s|+-h3iz`!C`4PU=#Bj&nC zYJK>WJ=S_*!$>90n`AOzM4on4aH}gXYhc_Vfr*T~G>9RnKHW%D%1b*^jE?8$qqH^zz z32a)bRCWvqAdl{oIk&szeC&U=+pv&289&e_3Lb8C;O5hVt;q_Mpdtz}2p7#A$Fpm} zC$t|T5jDN2AQ)#fti?^33%3?Ekh1+*#y4Wsv<$7;fmu#y_huFJb!sH=vWjJYI{u>rMV;L#N3!(-@Ye-lp=2M8oc*1`Kw_R54RE*U8>7IgP1{d3h|g zHNmPk`d20M8T_b>$e@=#TpC7SQr&cfS)(bQ=97aSeURxN1Am^W6ph`#tYa!m+@Bj@V2e!gEFO9AY9JXEN27o4?;R%9Wzg&XNXs?$F zl$wL`=d(iRAsrJpvg(S6OTeQ|5-OF3EZq^ePnlPGa zd8(HF?gY=?mv2mxP+QEDOKB39WQrLU0#$Se-a#Qd^l3UZ*G#3`$HB+u_`>Wmb|7uX zOU)-?-LC$0U!=61H_sh|jG2NJA}2EXA9nY%=)I^MwU=84g3;^_nWkA|FxX^TqINs1 z+1aH@2rZQbFMeUzvb7N7a?=7f&r0kguA0LpvVo@+wLn=tCg-G(Mzi^gBDl=?E^%7r z8*w<>@gL5-O!IRBkaK)LyiEx@?FY}%8`*3`N zz6b@L1Wl)Hs65v>Q#hW}9QM|(W5u7zmXH_2xd>gF*>NjxH;9X=jZmik)jLK91qX*% z&E%Li!`^wtDtesP&bLt2M7g_OKkN9F+p(GVi6<8d+%Um!;q)NoH@i!XC(K?n^Dik@ zHjA)7q}j0gRKFH4Q-nX+;^AuEU^ri+#_u^{O314bT2#7gP9k{FkcHv1Np!N+%M%3m1YI9RdMqlL6Adc^2%Y1i1?P0Z1A*&| znfI|-w!i!L7HHYvyyRI_1XfXa67xPH(dJ|KJ|tSm6b$gt7X zvc-8#*>G-d*5tR{k za;YoccBn&F!kWHzi z^EhETyJ+aj5V^&%pkSaU%0j)FBiK-?y8YRHDMc2CF=}HdXZ+#sd~er^r&QzDZi?57 zlLCz}MeKuOg>SmcIjjV9eB!(L>^AzC@;1)IH2R|p6~Q!%hYh1qgu~gm6yF+5l9j2iH z04(oKS~_8G((}08CrhlH)~s_j>M7+}isl}DzGprDt}7&Kmw0uj!R=b(HpSwJLQ+bF z?0s{eCffZc=r!k9p=(v;Mhm6o9*Yl)AI)vriWrJ&{fyLV|_BPA8pK~yo>GNx{ z|4f;8K3DZ(0s|S#`LRgqK;}3bp#DF;l$3l@7R@(zkacCb;~!qwapZNgtk;xM8KR&& ztcUE3rk{=w;ft-scvGted7jTIju|%9{0?WyVYm?PO*Sr+!?oPY4`*4`dYA9Ky{&(s z{ypDCr1mn#Rpl}Z47;>_1ZN8L(HSC>IFisw|Nja?B{qW^vGn=r_j#-)rlD6n#;tZc zH$XF_L&Wf!Pjo(f8tY{k497c8csxd>#udW%Tv*=}=1rP%Jxer9bf8gdaau{X%$YQx zHo2Hlhc2l5r(?6y6r%fij<2u|9m!bKK_}ZBM8xi>BK#Lm;Er0%hjrC)?A@Mg6~TU+ zY3?p7!Pb|9cIXS9;oP;SEx>_F<<|H7qU1Qre0=)xrbJ{x_|)xu66g?5XUYX%j)STo zQmF@A+fj2F8&ZtRKOXnKr3}lxPO1va^6GAmq02{15t-Qk6*Co9BS!H&?ZKPTH;alI zV->7TUr@WCF~QFN16+y$8hwKq zgWY&~>AC7=+gv)9JC9l`#^Gw0NYISWZ6H2VhZi^mx9&#_YE2$b@o4S&BqcA1th33i|^TfSV4R6We>?<|B}i^N(C)Gc~K;>lWae^M3h zc)Cwk5e_94m*p+j=3EH<B#xLO-{ zAphLqgHktr+}D1cmnBDJAIX~k4SK~Nu!Nx>HAI|Y+*DW3d%Y37GuCAGY*dJPzG&L;=nC4^ZrfC|LWQ4z&Yf2^RY<7&Yu98P8tvt>uC~y10gXT1C z6*?DYyDb8#_UWfJt67x{)#b|b_KaxZ9dWOUANjk5jZ#gC^Y-l8H^XWVZF{JTslPd} zU#&e5c#0ct_dGd3h|9hv%Dk84xFD~F&GKg&Z@w5BZ@*7R0|sv-&2~m*JN(_2wLWEq z_rGhe_GiT&%mo&8Q5?Y@uEU}&C*!H_F%W)incWRp!zE=W0rTx}^equOUX=WD0QD+;#%>OTd@9a>Ka5LNUOu@hy z0+==KJrDm=NSx{#M&-KmrKq|S!hP9{k<)cG$ijoP=cCID@Bou!Z*gE|ssF3oeaHmc znp0ii^Y_uK>MQX|sWxDI9beCDq^jWf^nza_I%OU_&)Gk96xW6iy`R^}gXheaD zd51}WM2l*c3X_mwdS}D+vy4x_7rU|CnIFof+P2{1dWyIQNIgVUsdI3^G~mE+t-)k4(gZk5_WFUTrAMoPy^gu>55jwVs|5c9Zza zwACI@+V@O`F$`N>!7nX$n8phkWP40PjvePdZTB?j%PW@yMqthZjfs$soSX^GYWoMC z+mh;0ugp5_h0TB6_AjOI=gf$iZ{wPp^g-dC>?RHn0?wn`Eilw5+OGOz^*2`VTG5A~ zb0FhgzojaF{QuZ{%cv;7sC`%v6i^yOLO_sC>5x!Tx;vzEsG)~eL0Ve6ySqU_LSpD{ z5r$6b`rqhJg=ek*dOy7DeLp>ZaLr=wIkC^)`|NY=YoGgKIN2kCzt@FROF_$R#ODRhPGZ^Y^s_g*Cw+y`4MssrqTKN#iRLB#DtLG-kAP?W2@8rrEXSIG}(^&s zY!Zl8HKR6D!?L-9^8~B)7oTpc8XfE4;DXXFFJ;x$;*(iclitCp6U|V@)~9$n&R^lI z-e&&ZV+DImi}^i2Cqc~ho9_kpV&B*Bap>I96H70VC6tKt}u=7e#BYaXvJ`_+>1dnTpsYQt^|$0{rR1=Pf}By_wc0 zRacjqjcLgU6H#xJawAKQF67x@N1$|<8gm$+_6b=t3Ped!bGFvth#r?tKuZ(Fn9Kb2DC|wZKq_&%eSnK zET76F?TO3!ny`~yM4zUy3Ur$F#wKtXZ@6>L6g0)<2+--0_n^GdI(#w8jJ=ELP-OWeqk2{1B?btp<9bFepNmn#pT`YQ!Eq0d`DV$|t@V$5iN-Hhw=Mis+fZnl9{C{WZq71Ko#ydmIn zqReCk>fkR{k0GGDG;6olZVQZ|UmdGI^18gZPW}Q}WZ81h#>b%#5lH2b5R8FWRv#AN zo11MDE4%vTetQSj+oJ?|3%yelT~hkf;u5(r%lx8D!~nKrzdS8~%7FpJ4X-w%L)c63I*fw=Ac_<@lzTzAP&EmL+T!+) z>4gbkUqL(aj{(XWK2R9otUCpcU#oc4YU@W7SDu%P1f0%))3OjEu1R@AOlko3P=V3^ zJwnk$isWSeu0eb75LSBENO+#?tb-FJohre#wq`RSS+) z(vUm+?njN2!`-p33I0BcLkUNz`6z47j2&U`t>CcLo~Cu(#6J3qiiNuMWxoTpYdKw0 zZtftINqG&_(%%JM5=r{;xp&ADo`U-2I9Q^6mzC#*MSGXrKOS3)5D>DxXv(0#KqB!5 zQZ{8k;%KJc5mk4{6cYhn^3D7l`lQ3zrV+7h&b?8w$M>$60?2ulQ;P##d6SpeHG6oM zBfllZaD;b<9F6jB6lSt4KvV67AwRxQ>{Wl8WX&c68 zAsP68r1EPXf~lCmO~Y;n#~Za^famcuX3ow12i*Owir%V|$so6K$j0$*Ul0jZ{@Kqp zzKsC7{a!bcjm*W9JD9kC+xbidaCG>+duro=h|{#?$G1Gk>Gk4Jl8co`@s{CWY?RWL zZO^#V$*C~yLekl$G0UC zPYLI}ftj*s6v_~H?J|E4nE=Rt40?@Tint!El#ZI`tMgXP+Ox((rS)ChFEY>toPWeV zugwk7D3kbH&pFk&XcTp~>))?u0l=f1H>UEj+r@l0$CPP0VgFq zNLFr$elsBCjJ00J4QCK4?4SAyzCjd)?bT!d;E(RM)=}HWhT}<+C6&J>ZTwa%7BT=Z z#N=gXg3QKgh|-g^%dxLV7fTkbKlwA`4i?XI&Zti9B*ux?oJ_1moW zOuL_E)pSw8bL4NxM`pg`{@m)1^RAbw1+olZfEiaM0@uQ}}WLXMb&EL4tV^(O2B zS#fvfEeaD%lhZ$poY`ND9KGDNQP3v)=(y3;rsI^}efBRj+kAx>D;Kf?E)L(nR?kA5 zCVOHfEzFgw($e%Zo7~6L4T1sABP8^RBRvz-QJNg)atUOMBm8W$$u9sxLlKL9d9a!R zyf7EIPyCv{(+|nzq?C5G*n=o$%j#-r7b=vMUj@*ishKTpR?VJv-o0YvZ>ZqR{&N?A zt+LTdZ%t7UaBd!ALT@kR+YH&P_{jFxzBn7z7zG>0{gm*W z545s zL*u<>w&dP(05Yk^0rA!f84F6dQCqW>>*^iJHj~dgx?UyL0hB6AAMHxdD_I?n5gIK; zP2M+w(WrMw9&_jyCptMMIlM-1K1#B%dh!&m?c# z{_b0}-kxzw)e5Ea)nbT)|B}*P^PF4xx}M+oZsxnXbFSxWyB+8}yUv8&ZOXZ&_4&Rt zWm5}&l)+0%=TXSmm#*`0)dDu2TFRxdap6u2>_UfphmVg^#1Zl6HS7W24~gsAx47QN z`BWX;-O)?}>&Y=@9nBfWW1}Pdha2OG=-exXPIJAbVO4bnuKmMU6(OK08Gv5;pPBve zy%WIzZms3Ba9K=z9W!gwxK(H*yq(17G-*7AI;w8cSUEas*s$$!zA#1N@%U<)@HD@i zz^DGON|Rl%y4!Jzmtl5K)>Gl!0Nj+!qbyszDhFmC)1`vacMJ})&=JKSugqOF8Lm{e z6Zd|bHf$9z)%gxkuU8|7GdfA~>!>TuWyad(P#C~x%lwl3 zK9D?q{&nhi#?3U2?`u!}je`k50B4T~0-eB~OWx3~^-23nESUz1F}0kBW3xSvi#;du z5~Wl~m<6#-voBukFZS_6wnkLhuH^K|z{h6US{}0kGWgCF3`@nFg_AkLDtxAET@40e z6FpD=DL3ymdmUCb{IDoDe~3sDoA(L8tPo;s=sXc0p(M1Tc7P?@<=tBnopvgYm3P^7 z7_+J#8FlsO8T~nZfj(J*WunD%><>#;)L%s9d<;n*gL|I-QaOR+Sl^(}8*;fBuzxvF zEK8DYm+7Fw!D~?K?J2MV@NQOyOVgvKo!_Fe$IAI>)c){!stR}@y5-@aMV*3zf*RmT z_(DUaS6#9V^KK^eF!>XJf12d_O~g};6KXh|LtAE>V{`f+SHU>mHI)1KV}4)r{A>qt zlq?|u;lU*=n6sdIS%dd{iAnUcm^G!j88Ca=gG>Xevg8|~2@|k2#WE#H49Nr!ntl-N zb6+(p;w@@~;&-bD-$?%_BpYA9%?i-?xp((K7509jeMz;}!`fqW7=+!qb?a932TRD- zv978cLr33ux0_D(=m09mPzLc#{oH^21T0F=pP!z`y@f^EW)B%f z-fNFI3046^GK#l3Ti>3}9WpXWju=LpIZB2M zeiM~?890MZYW775vEuRvH&s$(PUL9m@VOxP5TGfC@Venu7Z880U#S@CrD0&fce?)` z@KRRGE)w*hC0mO=^Q_Uv3P@L`;bE5N`QmFa;>rn8!p8GXgGu|R`Jx(2>IW%>BK|23 zmf4_R=da(vgcT5?F8P8XqiKG*r-xgcZSZ`0Mr~Q=Tq9W*(nK!v-f^?|s@eC#*ir`) zCO3;cN5&Vq+%PLRAWkC^w1UxAgk*|6sL;>Q##RW_aix#S8;{E`jL!Ms zo+k}IrekkQRw{f#@TOtS=RuEp;p(>5ws0)8Qkg|5sng5yNAs52EGugHl3clg4u@sn zNUSots4`wf$P*)Czdx=`kn}zfGuhp#P0Uv-^!zD0=>L$GhH-T)yO?{uiAIYIRcCKZ z19Tv)k4;r=Nt~kHdB0#7y{_a$B7 zC-Lmyr&lFgb^eCa`v4JmOvfFS+@La6NVfV>%1ZzxJAsjn}E7c}Fwg^*`M}0^0JKN3=A3S_06P z1MUdJ}lmc(7ME1v6a$uN3mA1{B=xB%*4gdycneplxJPBJnpV( zid}Q27tt!F>mPo8s-Zr*P*UMrsjvJSV%~v2kpb3ej_)z;*{`&1(y6a6Oc%l)Ty-7g zYnsPwR#lr(IL?mBF3I##7EO!eaT z7Jx1uzq_Y@_wO!+gaeq9U~PJT@JVwo$M#6Nk=4R{>evN`iHw9WP|ne@E;g$dQ1Cmo z6J1^t+LdUYxLT=dJ)vOTC99Zt0@wqUbY6Zw81s04IsRy6b_Pky^`c+bq|fm>d$eEN z974=WDLEzcRRu==Lk+!MMGWn4??8#lJ22ub+@P5Uki(QNoz$r2dWJd@SKvJRE&G!X zP(Jhmh@Knh%uFr076&CQP3?DAO+=%DsRdRIbtA>hs{DZKqg{^vWLfMc_LgF1nTG*F zR!&Y17r1|SdDurJ5L&+$P@`|l+V&&k0aW0qtJ{g^M+y+ynrY#CHUfsYnqvas|4)jukvx2{@`jg$1+-LZ4uU=+wALR4FZY%&zB{zE{9ZJGe#HgSu=G%Z z?)p8M1qkWV%K)Kb))^+wlnx45Vr)9pz-}|ckMfV863b=xYl5%>JHix#E{nywxkwM5 zGW9j9=l8jen;bazunsTen!a@yWe*+@Zn^^Ad~?ZBP*Cv`AV^I^8R;iXY40=sE*g?6qBDcl_DEg9%h*v6(0;Gv*&oxrf%oNiP>oSvU7a zYsOtC9jFazi@(|l5N=q2(XIlmL9q4!g|U1M?+};Stl}}ic%}M zrs{kVDq7QnV43%Z`<~aa;$Bi(p zMe7Y1PEpjJDGTjro=(rBjcC6H8Vtl{JbNYpZ%s0xuNgL_+&e<^P}AaI;U>{@nnSpz(61(6M^W7)GG%#Q4Z+=AFf9A8X=}n02G;2M$_iXlr=X{f$}^m`tKf z-Ol+Mg=9uUR+Zy#yqt3Cx-4Ww6@O=HntR9?n!x8&H1ui{yfeSEkY7+y#yS)W4d*Oh zdzpO^n@#1u)1>%qill*Q7)mtw&$*TWloFso4<0lg+kwinJTFw>kHTOKjmxZ!37~ic zzOjsm0pMgUXo=0fs;{X2HNP3XNd*Xq zt&3kA9_v<(w*>A*ogTVhdi%+|*rU!akv#p4ZTD|!*K5QAlzR)(T_A>(jpuG{x6|fz z!gttb(D7X^AeLTs1NehgllF@7>B0bS_mio9Ht`etb~}B$uWFg z6)-GrL>S0BnAre%N+%W4?EV}RQeIKooN_Qz`ZM<7!#*(7 z9kKzI$5e_`+gNxB*}T{SGnqc_mkG7+>RblMxlZ3%`=}8dmLab#h!<~y$6~>>ITKOW zQJEN1=kte37r}$3iJ89LZ6v_9o^3daWVLQ z)ylO0{9P*pudn=}xBtTgr(#xQbhNoY=Kg2^c~iCOU9WBT^8tWTl>tYM>S*>|f18+Z zR2|%VHDzDEx3~8&KceOJcC)}HEOv5Lrmy^9eMn4pjwX6Ys_h~Bx^02A=kY>5%^Y%= zaoROP+}`DQyq~;jM<2T~R?zJvaJrI>Kh$Qp8%}ZIdPLmceB~9Ta=CcAK5_g^`C?+< z7{G7LS<`>8&mD~TW3$CL%*{9g`GInxKj-d`+y9}!orA8wszPs}ft=To zFA||Qh96j}>Y=7dCf{FSTp!*Ay)j4==rF{F{%s}KE}GO5*iKrORuj;#qb56 zEZr%%jgOLkB=!cr;8?aYH%Dz9G7$PjkNPVa(ktZgn$MyrCv#cRkKR2`uyBwWLU{)+ zr2kco`F9(8FY&n@u@K$p{e5Ai)>>Q-Psb1d`{j#@{tw>!wcC+O!#j>91>+1V;ne6q zeF!TE-AFN?&oRLGvl;&C^&=fT2V+Utt0Hr#E1%v&n2IUBaHUl@j|lAzToCx^bKRv# zVc07<2I+|+W?yjO%W4PBE+y(efa&kSy?gH!{6Gzb0$s~I(QqdEzNi7&?e3oZ?#^+| z?0#V?QKH3?DT#|^@4W6`gTHPsWge*lnBdTH%U#l-1+DcUHNy9P9oiKLe_@pCiHe5- zeK|Wair8(E%@Wviva?r7UMQjb1v#&!fZ+Fe+xDeJ1_sd-OU1LGbZ}w)-AU>fLZUc+ zf2DaXhrJk}tyg%xg&18nA;BnnM;fhmH-~gA^%)ad8FGHCB~+hFo?yZGQ|aNYj(&In z=sz0inaX?j&$~z5WlgK1n73zcf?%ojhWz~t3qgG_exhWLFYDiB|78%D*l+9^lik#& zPv4~nx9op&wY;r5ZWM18;R$Ju^P2dg-rL$2)i}^ZigM7J_BDyj+-wO6-aBiwCMAbm z0m(UA5IF2em=7b@Pyj?{_gTSfD zici098u+6IK$_*#0CyXbR8`O2YE1!l4vz6`;{DH7QnyX=oHFHupsjbsq&!Kpg{iFj zQ=-Jcnmn$-q7N_vvZTdpF6VFC{bkmaa3ToCK^X>;FqHRY0uk{tiPWCbNq2?;=^wel?z0PbTnpW zF%CK^^QkzuGzWG)xmv9?-kE!NxA)TDvhVIG8kdn(W>5;pKyWMsuxBt3I& z@z&b!k5os-n9w7tO&{D|TCD5mD%z1~aM5dQxLRum6+Xcch4 z9_TO)gLiJ$VMCWTzF?!w%&f5Pm=SJG$=fcNni`&itmOHtPjMuZHc)4alfAm7hyCw! zTL&NATncB(6g5Fi@2|K|FrY5&qq9Chc_n~5m8F%I+Nx`VAn`&MHbuF?vqoV%KZ?`l^qPv>#*`htI}}Sr~f#}_l8{o zAGZ)TE}F@F|JGl1#!Lsj2 zSjB@|27v)Lv%e12MHYe61}^U5jM(u5XJ%#sfhLEJR>hl;Ltpf2F|#K(*8(N_b6Fk1 z%zpeb9SSuADr{?Ew0Qv?HauJu$2#@2j4QY0C*rr1Cc#vwXzvR3B5wDOSukh7qf1edComexpRK5~C$ zfA2YW`8nAK;#-=KGK0K=Hb-xo3%La1ZyY?lN}v*eIYq98J9T&;tUOakkhQ{&GjS!Wz-WoUA2XTEUm+pS?T=gPQ6NY$>qz#^2_Do z{Su;^ONc0wQe~VATrB185o$GK->1tZg_4uop8JHB7H1$TN<^i0V2~5+app3?;+RI4 zfF>(>LpNK51fBN49`R<6ipHKaZS7Y&I6KF|QYjGep#J{RH5((1kk{bhBI$dua~^|} zG9L5-jDP3-Yu)pCC3v(`7r`0tYauM-1l)W$poMes>LKi%V4v7w!J3ttsdd0avy4d) zW1fl4zY7-tYSL${{+ar%gY5%oOM9^$iDS=2W&Q04 zaMNHIlc}ktQgXk}9v`H5yfDvcmK*Es4H1%JxWZ5B%0RF_jjWRQbt^kCjoWfBuBTv}fRkoy^_zvk$!O^Zb@uD>BjwgGMns^oYJdeUfE5Ex0AW&T+&S!TA2F zCE-WBQ)Jlp8nbVMaVLR~%uX};vQuP+nCk5}<>e>77>ECflYWcJjY>|n-hMajBu>NZ zF1K=OZWO@9*qjcl-{ojPhE2|w+$YEyD(HScG9qcAk(4!*TegS7Cq4Fh8>(HYAAn1F z+cO!83tA+YV*5}8;+cOrS?XRZ;NYXlMg}fzccd9@LTH!-0AGvl^v$p=E%P8Nc?SpS z&FzcVWKmCe99M=b4LYE}hlOu-RXtqScX#|zO+gfdpY{o&3(<^)MY%dkn)So#WkNaQ|7}Ri0<4)x&;$E0RK=o2m ziFC>-P@>Q7_(A5>)bIiuK<0yJlTtivaz?fvbVrYvJsmL$(`w1SiIm3x4aNwc{h{bc>eq~-odY8iocp9iqK;( zb`jj;YI)hQOaGDh!p!9%wVfz%y?E#TtKL3z_A9+AL(y|ZS}Cp=EJl)dBO{eW62h25 zUHtRHi2pPaxcw+b&~||%G5V&rxZb!-02JtCgU|BzsSV{5aOi^LP0qUg_=yw%3nVxC zZQh)}mmnM}ZgFUT9r^W||6m1x2)9Gx zs@pvx0zDRaCCU!n^tyMv-2e((Oy6v)iodeyG^$BcyIM6_xT#TA3tHcJ@x+; z_Wug|?>6zjn*G0S+)mG4|IRsaU!69FD^9Ao?j;dPp*PPK5|U+< z&MO<5bhb}VeNTPwU4Tq2O+DB-*s7YyWsSAqM3B7EKkaox^cUWGQwJJ`rJ-Uc!Fxv{`%5^ zOvHeduI?^?yG`5GMN8X7*VT=?QQHabdM_xict9UBln_9X!e3WWRh3osD->Zvz@v+x zrrHCG*!tf~jlUX7u{YGJJFo_qal?)VHkKPYQ}G9*XFES}GTq!)41)#trOKA^je0{b zg%uczJ0k?bRJ&P)LhKDGHTGjWGuS8^2(wp;%F-HyjEwxc0s!uM7_Y8Q60jS&h*q|s zNSXt9Md5&Mo1JZ-nUcD|^T;UG$ml4sS%TM$40JTXK@4~n?`pYN#-;8D2Zr1@2YcIK z$dzQ@4<;a3k5A?qWEFR17x-A=rtWUY5x-nD@|x1h0C&J)t^euX*7(z+J3u)X5-iQ@ zE4R{ptp-E0gTc&GLI({YP}CD%WfjMQoDDUdfz*aHg~fJxmcJQ?-#QrWGU`39!G;(o>Uy&<@e{r-jFgmd$_Sr*Vpj><}(K~x}E5sM=4z>J366@{P2Qn=}10;-h zHE5sR9OJwIoOFv;gX!h>_xo(-CY2UJMUb(gM3*cmmYEC&v~nT!Wv$BtJfKLtbBi-Y zu*l*_XQu9Gc#sk#%zgWOQYI=$=*^(P>@?uR&5=V#M_0HuS(&G)x@j6yX}&M@95{xd z4Hex5E<7A2l8neaE`)^6&g7r&*iUZvZ%&3VZ^M##fs_$08cD&5T1 zJ2GS+u)=mx#;mbDDlxkMp?_ebi~GSNdR?93iczlB-)GZF7HBXL>}Df(yc00`7iEnh zB`#oNvo@UIFT;I>)~2TYU%L~ANv0(pCu-_J&k=#sa{uAMo`R8|%zvk48$Ms7o2E;x z1pffmm&AJ(Ga1(z_r9&Itue1|T|0-FY?JY)jn!epiEBJ@N^TV2S;25Fxcb!=M#ZfF z>Lq1L@55^(goE{C*ko;rsg8IQ`mLch#rS-Cy2|{iD_c0rt*& zudP`J^kQBYv!Ta{zUKCR+kwmH3JM<<5)PmB^F$kOMsyN&H(hjhO;nLJpE8WzKs0f9 z;AUFaWps}A;RnXYb9mimV0Z+PJ`Z3$It#|K@P+Pau%*W_bQLnPkQ+m8c?sY;0BB;# z5jgs!=`gG)xA1}3(2J_Hk```}O~>M$&Lo5a_wS|gq}Qbie*g$?L9BkT{b7;L*+8#m zjoi_>cLf`rD6R?@r}aYG`@noe1-b=OPwvBKof65fXl_QkcH)9g2X)l|hO{<3)3kL0 zR&(K1RTf;B4$oviJn1BEJS|mq=5^xByBTMD$s*WW7xTKMOC6m9Cv`UkB764tSWvT0 zhs!h(;FjN|kBl>}N|ijkE0^S1wEn)C&os z+P$whGmVx2Q;3d^UTR!7{Tpok(!zcTL8s%ev-2`LBl^4|of>D`^;qUBO^k&E_@D1b z!o-$;4$k5juM&bgZzs)2i4g3QtsT=8!E6ZzScQ); zKL1Fk*FKfB7}>sftq|hD{xVJPIvC3I1AAU@T*5+qRV9Q$h1=1qm3q9)1q{@jk5;9zR&vJ3JxN@PT8{} zDN~kK5~wnhws}M5d#j#^uvA-O)Z4gS8q!MA9KCJsd0SzrV5F2Onh-IRAvbqmC80rC zMF;SZhBFGcwr(%M8srtI?$MQb`e=W@RwD){$HszI{(eQ5w1|-(25)%QO~-Wy-q&mE zeAE1pU>^B-G_xOAT3UM4C9%#Py%f)k;HKt~&OdvzeT9oj&1jQ^q6mQX36H4Q15=b7 zSH^R}W6ZU|Re#miz*k2V>3&IB=;o#MXFCXLrjZ;QPKzQueG!zu##ynb_Z>tI~ z+{_gn3iBQZCpXZN z6TplIQw?H>#$+6BFVq>ZLO=?G<2r@W-_&lHd)OZW9T{a{g?v_Z+)&bykEL+Sxt*r* zc`YdKr4KieRUV0gWZCAI^0cYfKwUi=u9wc>5AwnpF<|r04O0@*y+{wexdvndbsNgvsl?-@i>z|P0 zKy73Q5g9E)nt|J#U2GUVUCE?e?()@TRws**sf=c7QN%1rs(-`(d5ISba6J-RaiHUYTK{8L z?=VT|%SCK9NW(E*0n5vM5ys*N&29v2zB-dp|Kpp}vG>79mO&rsl9zn+rfpS69Nu`Yi6ail9ZTgl(cV&tEzg};6fw|@O_>-Pt!v>yiDODQG^{5%T{6q=)v>>q zsx1>=4hk(VBHclbVwmiEY z+54fS$l?dTa;#KV;K_~5mdGiiNJ>n3Ba`*}tFO9H?n^^E6x zW(B=EY}MBF`XFc$U#UYSQ!>!Xdr>tbKzwQtg?dIy;Wq~FHXw{ZQw~|9jOhau2@!%k z1dVEOQk3#%jgtoC+k=B;_UjTVjCQm6d^+}(UC+!R>n_!v<$9r-?s@`vY`V|Ns5(7l z4a;MihIBS148a^5ua#6GyVr8z`Xv`}Ls^a@U`66OjhRlT^0oafp}u_nYvxS+FUva~ z_?2`a^y2w%yk4j)!SoKsEZb-h=78jKym!QR*jh-4p?ym|)Ia{FrwsZaEovO$#-;~{ zWEku9YdM!tzz0c$?6hWu$6&#+WXeI!td@*gRsH%4fBnFP2Wq(0q`i9TzE%&o-DoLCP~R{we2C z6DGAHNOA^LrSFf0|4252!_0&1Q`1yLZHsUgCCEU_J7ofUth43YPoQx-pm81P%In#% zuyt3~)b|#NkW(o#Zec-RtDZHbJ^a99UtX9BklGn(Y|&xUkv3@Xy$A{`&KUclg7pTW zETn7w&Aw^K@Ky*b&Q|tijzu%2V)z&ipDG>GAH#!?-sLS$eE8gs1ezpBd23%ng#rs= zeBZEnz@j`wnQ1I5WFX00W17N51uD>#TaIGz;<1&@H=gfce=}qk=-Qqrp~B{eR%I_Znb^jo#qo_YM|^ZS zeBwa+)ldGSq)}rn)7p8>uNlY!KmKyF@6WQ&u&D&hce>19tIHG3#2RyhY_cNOL6)d6 zi2z9~ag!DvmCT-QIX&ODbg8asVw#G?FVvyyKGd<)tSz>tgtI!q2eX{vr+j(HRc11> zBHy@VU1TBE6S+ewPsW(D4bcW=2oub7^JGG0)AEdTd{J2GiH!g!CZ4+aP}D^WPxzZ~ zEM|z%)Xt%K!BJD#@V2~ki8R=iR-)ad3o=>XE0b8YR^!V}0siPXIWKjl;Na?prOdF; zqV!2`m%5duS_}nb!rZl3M=C+t6=Xv+>07O8(0CM_;bBe7Xodg9JOtBnC?RPtGj0X2k3jqE}>bKU?R}TMtR>`g`i!e&egj&j?ZZ5KQ79ts( zRa$FlM&R(Rb6kO=V}&o+=TfkWG-pNcG+oB4XcRGM(?Y7|f#(s`n8Zl=`?S@^#_A4=w<(1r-?{f?bf zS^DcvnzW){8OUQY(>9qGv|JwvucLI@epQwZdxVP@(+LI@DtIW#Ozq=8!SAI}4hy%* zYtJ;7MbM~ov(z@TFfDq4ewmXAAEn%Zk7A~(=dLFuJw>h(>V&#FQJ^rJZyMb@Ca}7@ zJFcD`A>A6`ljC}44T zDYkqHkH_NOIby}9m8x_maPz1h(WK3!Rlqt_@vMkEqHT~A9UE{>sP$EmuD#^U3^9z$ zB~v#q9!N;FQ+y#rVJGyf9ex>P0|~3WK|et6%y-$2oz31WQeL;>IQpG$vk_%G+~2%U zONgv@0xFwJ9&4N@9zAKnW|A|F6Phl(JB@-$_eq`v_4pZiywc}fQVKmqW{RNK(}q-< zJRpfkj=I4TuCP~2grLC$h5VqFNHF#9aEwts!`%$)aRf%4;PfuAu_qXM1*nnGLv zO>4@lz)aGkk#7EZ$WK!ti56l9pn|B<7R5X-GnWhN{k{*7e*12p{WX(fdT69BPvcsSFP<>PokXl`2qM3ZB&13eh0e1(D zdCEp%uin{YDyhMN#@9rcG}B|{VsBSp5zf(U`%XD36-9G*{6tlUq~A+QHw1+>zgZ>{ zrWCv3Ob|d{Lu6_<5^Lubl(kWavy4rAbW%8imRiTZyy8^2Fa}(6uR9A#WxyJJm)+eX zw0JSNn3^{yGC7vLIrw9aP?okBslr+pm7cblk*xV=211^@`y`{Zj6ZtIm#6(r?(AGd zY4p46WP6gbkuJ7qAgvU3>?hbncAm29o)LT@x+l7}L_N&{VtCtz@h>)wOs~26se_Sw626+-LdB2mnrYZ)c6PT=|76$&I zt=m}jTiJ`N^XCf*>B^udQ*M!q5WJyIVaixHPK+RE+wN2KTuhtocY{$4BvmS=XXZ*V zRemZdmBL>xEabm}YN5+mVfE|5xwREkjMyx#(w`f#^EPMCQaAJERb%-o;1@*K26+O> zih};19^`(t_BgSe<44XK!bnYgYPk+(tc}c|KU-D`op<4?QT#owLYCDZsN@Nfpnb}cm&N}s4 zf4%ZT1P%(nXA4Y4Bl4-YjmISdQ^Nk{euqp|P=0CSR;DEr+r>8`o*@-dnU;_^jp{Z` zvTj~DIFMekXPn}x0S#m5)m4Mozp;=>rPE5QeC<9Nr7N6C zN@Trfm~N&Mr_04s7$P&P?-rIIZ;VCkzOeiuC)B6m;{4mg4D`&C^If>K`?$ns4Q#az z5Omz9;gMcqx;5NBeaRV-jmSGEi@}On3c%NSw(Ezn8evXLo}mvK?n+IHhvrQaEwsWy zSrfo<36+rCXvxOfq7N5&;>9`9Y_QC;4*vOZnRKutJ)OnFA&LkY?i0Eqa3nrA?vWPH zfina4jAz4RQZY8C^5~}J%nz#IJq5-v*&*wo)n_`NIdl`h)k}{SC9Yf!4N?tSyUt5Y zKH#u;2%h*M?5^z2VAmxXvp+Jua=F-chdZq{zVatKlZaa-w`mvF37b3b$@%N##5oFJ zZt*#aB*_9Y1ZJ7!$<4jVUpN%RC4-T{(K2D7%zD1H@E8KSg~|uArza>>kp* zq1lv~FgHqUtus}E)c<9h8v8M>Sy_1_@>iBVj_91cS9#f$I&HP3(=!#* z$kx~KncW_N95*jQAP(;3O4}#9msgGw8o?TG!MqTQ-%P82oeSw9_{)qTVa-^YA zjK=Tz_1j1L`^y)W3H;kSpZZ9g`0*2h7assVZ+?}&3lBTns)zXoiNcYrDsX zzE>C*SL!^y)LhHGaxSi#FcrZGQ$1|9Y~z znw@egypMsds=GW0Fh2S4-LQ)sMvg#^7X{SBl zkn}hgTW5ofg@ONr@}p@Y&g)Tn&nc`6%+i?y4UQe<3!)sbunS+tDd|s9enAJ&(ATqHp^){6%S?mENfgz6k(0C z@%!;-{sTiAJ#!&bdF<+}@d5({B}c*yqb^K|A%=n7-ygAi4A4-d+_B1m#;~D+fdQ-^sM4kxv)}zhcBJbu_L3bj@uOBR}sxL z(j)lHu&I^^O^Ei*HqQu?z1r_Edu7iP+%B5`!HMYkL}o1Dgx*H8$qGk!N$0g_r%V{R zUDgOm*mHo##P22x`0r#zCV#z_fN;68no@)pC`xL7?!#ecIXa@_)jotPhO&BDKem4P zYN8&Eizc9;`GI&ik@81FsJgr`LYvoKqKOJlkA906_iU~S)O9(b&&G9aHC6|0?z4@fyu9_1{P7W1zV0oa3&JYh#vRX8_?aQT8Ly8=r$eNyWPzkj5Al^82v(sM*`*cLxB!~q&j3!g6bD--vE6YW=ICEGEGf%%% zz!IY6wgF=JSWV&rDS-q1FaZCCMy!f z_M??z=cj7jw(U%y3{Xg zJ~e=mDFt`vJrZHAmgHKHS)n+5mfwYX7~IlQJi7KVHn(wrUHkyv1OIrOC7fXO@Np!} zN)H{uEI^9Gt(CIIO#I89~(~5C@_}vLjL{nFD?t58NHpJh$+l zlr!Pmh#PJpr#&R`+?+X{$EmI|AwcKSV^tG5Q<&#|^r&Gh@qnt3v0?ZdenH9*J-ppL z3}gizPk4tOWhEWY@Td6PEK83=)YrS#WlM#W?Ili*v&VTxwa)fVv9&yR8t(MrgkK3+ zT%EoReFdsW21NEXD!> zb-PDx^lvEOs1Vh>c}slISHEUxB8KjiXULB_sHQYr)~sXA?_sWJT?-|}5;5Sb8x!!f zC-8Lr-l^gnRa|{w(Grv+uDWws5i?cl$4D0KWik&;(mK;9M{{iUzL|b}OIBbh;m`C` zKd2=|YQ?TzMpY0E%e6qLBP6n$>=Wo)=!B%^&nT|&^T$DDGM0$K$|r-0I-dH0Dx>+! zD4n&T7Cw*j!$B8RzCQ5ZBdb;(Fh7>vd*DSM?@ULUr=jE7fJpnmToCccbO(EC!=+lI z0)Jv__|5`Z$3r_wA*G1;K?Ge3!)L%GG#6{xJQnWSX=KZz_hJHtIh2ecXf=%)=)aXkRN4AQCgEw=yQ3>R4d)1@zk@0tVWTsSYWFzvUUk2 zl1}avC2}lzaI9t{Rm+<|0q&B(mOPpV+|nxZUc_qenWk1wAsv;2fXpXm>o0EUM+)&a z6*GB8-BpE}oMzKp%C!Y@MS>~t&Hsn2a}1KK3BvW*wr$(CZQHhOW5>4b9eZ}PW821# zefztL8*%^kIh|e6m0hR0^3C`8`u+gdG<>^TRBVYJ?U3i(+AGM=?~_<{klx(cM~OLmqIg&y-vrD!xX*eTKVa~cXzAx zGEI&5^;~3EGEbn5Bn3Oer1@0FwdeFaq5n5_yxnw$sVDbc(CYV-*$aa{)vi2l3#)R< zWy03#%2Q;SshL*A3ZZ8~dRHsR@l&KmN%{QPmE2G&;CjYtLH3H{v6l(05+D<3+ejY& z0oEhi5#Z&g;GzM6BuYNYyg&`HNo>CuB5@jN2?g8wU~g{qZ?I|Y7R&y-iyBJFqmKT2z=wJYW-WIeyOH`{eV0wjEV|61>tJE0oSrT~&pMNvgRU5`N@?sgtIO92!DroKL7=eLDJ`QS*0W9H zbI_4|yn|M_R^fV*`U!I`t>p&|h$XpXq-Rdc=GK;5uNrf2v3P!nBHlE<7&IjMbbz*8L|LdqQ8U25W-0bPMiX?JeOAaOlihZq3F-RWx9yqll<|g8VW$ zJumS5l8(`r{pbxUQsGJMvB_i~vdOot+DHH2w=IF$hpW^J-dDJiFc8S@5h1kR<*-nx zCZk(Ib$)Mz6ZgT?BAZAYh5x?Vbc>>Ku3F2Y;)#F}9pWk39deq>&3MS(bhKTuJ1&Yu zps-W1Ji@r7O~f%_5+DiqBoZ<9PAg<&A`w(}gIB#geA#X^9tI$lzn(ux3S?r_XO5Fo zi&V6t!J#_7QtJc(*i0|`qfN#{ugh!C-QMfT8jsB;M}7UkxZvXKx4#X=rwK5#ie!XpJ>P5Oaax*b%JW|cN^?)0=qmuL6JZ)S$YCg?w~xt^h~S`b zD))fnG))7pDr{>Q@zo@Fk${+@!SvtC(@gSN721lYV%5Pxj~7wWIZxA+O(>Ku^L>1f ziPEQH;wX1zFU-7!N1@Pb;FcA_65Z)~gn;U7;5N&4 z?A=tIVT~Q(nk!w|l5Jr!d`qi16DPtSjVl0&&DNl-3NZqe2&Tr^&L1Q}vzBU3*vF78 z3*u2fdSpfNCGF+?Fh=el%%!Js{JJz#-(M|~TV*c|JbEa_;YwB;^wXM591|!75ZY-F zIIt{D1MhvVRA?61|DuJ|YnIM!R)puxf~(B)H@QrSViOHXvS!uPe!zf{CyA5oV}TH5 zM|E2S7AMv)MUY(p3?KT}kWTJ8r0~Ghz{FtwPH#DH+V8`_ou1r^46d-e1g9*}V}IAv?2t{f|x6q2|`d;i;pR#W`gWtpb0v2VmlQg3K1> zO$fqM5)M{_eEy7^HZMejdUn>A1%eUEOjpwh1WeSTY{b%m-SE&xX%eD>k>uu0)Ph0# zn9}OqO_fS}3O-xyKd;k~+E~G1m1r;Ar_yJjm*LkMTVJ$1S>)(DU06F2&rWphK5rHT zY~4e+g0nZ#9rz~R!PQY;{_S~t2t9RaP0u7k+P|_{d>3V<?5lD||;Hx7md0j#mjY6|BPOLJOIaVG_y0mjIo?4OHElrJ@|T85!PE>rPI zDeBGqalUJFB}8k@q_0#Hj`qKk%x;VD!Tit#d=j0i z5Aay5c*RLao9{R<>M% zxysm|#ru(}#W)+9$@U1sOT$%hjhMrSZXlyk-BnZRvm9t2MLglDqB^BcrXJo1)vVRC z>Ud~sDtRCpx+;hVxADGjpwY`S!m}iL5bd_|{bOiibd`>Ry*3fCr}VQn8F$`-oskIh zQ7fpyY@l#e?h;YwEgD4RUuYM(G>kZ-o0dce?hw5|m6zdOyO$@K2ju?=lbjf)z$2Z zcVrM5;-dM-h~IiaX1dZ85;0qb}?gBs@QZiJ_WAHd=yiO-V&t!sWnH=`%cS}FB^ zOI+56R=EBX!hWVpJ!h_PT}xv*0U(sH4N{-Z*P2)XW*-8!E45v&I&8Kpee&Jy_Bss! zdskljUEkEFRZGufX@7naO?Y>BPuMv!q8_`qJVe1vCg%l4Rf^k2`M>!4oM9rr$|3+V zeoGaOTtcsR)3Jm+g!8|-+_;O5H5IoCq@rwbd@CP7*r6`Yb%D@j5uz2bWF>xym}|)^Mmu@ zj+f=p>TaN*>B=;Aiv@rRn=v!je!bCQ-7>gg`?6c~_x-ZAt_LP4&XtmxGmo|1)Tg+G z%MjqK=zDvpXyA9fd`tPs=l%|tX;P=ts?{d?L7?A#-L`q7YyX@20DEL;H^A3?B#Kiq zF`MN{^R?dQ=JRcC^jfFGz%riQ^J3e#o4&(ts}cZM;8e<;nT&x>r3`pK&bU)sAf)*G z91ep{Etj+2u2w%)*EnyzsJ8XIy$|fZSt&QC?V{_xFd5Lfq)8oLmK1e4u)+~%+w$fb zFM{iXa(v5N$UM&t75VcN$vyNNx?w5Cg=GfT*eVnh^3EQ!l2I(VPwWUrUO8$UI#kiN zh<1a1m`&N~xm$NR!VIsLn;pf?>;_~t`p~1wwPrWthEPW7?mcz$6?&n7o<<4)OMv`*YBez5{ys4E8 zq08;Fx;nf6N-nmmMW}FhLf(ewl?iBCTxjw+L6ZDbO-&~Tiw-KAxjaeqg&zy4q1=mUDmT=o#DTK6pMTEzn>lc zcw;D>LS={At+rqPJnfpVB}=;7?e3t+-u^b4Y^}<&(CThX;g^HKSFCzKb`|tB15-PX48tl}2q>9Zc6Ix=j z8-1bwAvx0f9Tkk|8T@{j4`LCs{E9Jm!LIG2zFNNW`Fg|udjmJjIEk*gAJ2IWvcRtx zHuGf|feQdzpIv^_ft@YnSnndld$iTtvD*IqgxS!q3}E&W$6TU;i$1#FRmYT~($eko5NBbqv_O$mt%w$OZCmnv~@x-LtYO#zDU`UBE0FuB+A z62}GuOn5>6`1f)fLWgXFjeF1DETG&WFT4P=n&hEmFG=*t;PZJ z=mYz%^YXvDKZVs1#$>Uq-W#nsH+`3HnudAYIrNEctku06G&NU0fxdZ6M~xP=4b#hH zVOzMiAdgoTemdA(M!v`1m=ovP$hrB`h=jzvu8B0b%!yhla@krU;59=Sp^W5jdY3t(2RJpHFRGeKsB|(8oLcW(?Dwdcm8>x$DzkHZ!q$rU z56-}m$!0`!CVQC~TWfoQD#eqNqr)DFUmOg<>jckAxIjo3jsIujVU45cN!1V&AMNDv zW^%{KIm0!~mIH1qdo+g(GO$;sq0FrkOaBmgNO^WKq*Acq0jD)GgAh?EI9CnKVs=NF z*o*CXw%5UySzcHo9l^*WN=T*?De0x7KhE#31VTpwfktDdva+%t6^6ztrpJ(A3ReIL zf|n@_PDRR|q;xfVMVT21hgySHC!v@Dx5j2-x(XsjGJ}D|$SyW3Od)-ll(C-J-7HK1 zm9$CyLKOTF(-D&Sn2bTV-e5+Si1L}(|1FzA?|b)$(idUt;a`q}9DfEVT&Ioa?O~!l zuT$cbW1sa-$XnDT^nPzr|D9Qez{lrPLL|Y6D>o*~jG_kIla~RUeMAdeXP71g7v5)P%f2Xi{5@`|-_QMvDO7jT%?i1;1{(x-Wl zhk{J~0pG_Np^98L07I=| zE~Q}wv(WMsE1cov%kEuv()>$9R%e}xt;*6mmYb0Nz`uT}(&R+uwn_JqaMVHrv;!nG zgN^16DulN{?FbI7B48*XjCFz^Ho3CFZx;()sW>97ir{>RkT**_XPre((T*FB=L~p^ zhzyp1-uvw@M7}+MS=tNDDD+Fdmp3A|}AH%>O{ekDY`4ayH zAwGdrB0?J~T0L#&^LWmIwvw``iDMS(Bb#bsrv3V^;7Is4_YF3fl)d?;7}eV11HkIH ze!Uke+4Ew+L?iz?P8g{7_E%Sd>jlpnUoisMSAk+1USb3}GASF!;E9u_|J~!00TLg` z(3S2xR6jE$i23ABE05cDWj*7|bfVGNDX};`d0PLgA+)Qe6g|z{$Hc$1>#@!5e4*qo z*Vi(#W27m@^WJ?sXGJy9SPc`Y2(p1a0 zld75vka%NA-+;j+H9D{^4|+;N6nu>6C8crtKm75tmx9?|b{aPx$c!|z<^+EsnDId;_gQ0A<)(j=v@hrDi_|m; zf8O`*PxHHPQ8CpTK90FLdtGcK6FHK>1|6Nl}imUPpU9NTbE5-&ON z-_#zEHl$`Z#{mr7Osk?PiT+*SEoOAaG%Agdga^`hfk=ml%0<$Aq);r52M51Oas9%g zO)bGo!{0Zml+u#%07{RcarX;E)_Sd*J;(g{ivHn?6(%Cq8+1&?;jp9HE=G!m@j8LT zqaMmwSBbn<##8)kciKm;$p75cG=P3dMnqz8)dwI>1*l`8TP)yBRXzF{bv_eu9oa_a z&~V}!4$sbM@oe@*OE!WZR{?(bbq?EAEDN`ZZ;vm#fttj|cIxU*52v~%JHqlgUiKrp z3Zg~^|kEJrj$!z-@OM(ua1PU6_<)o6n?owdaGLB_&1+7|c!;mG zj=kPI=oL2eY^$`0 zA~;QqO1`+cOUY8$c!tzPOEeZzuE?X-YG%o4q;RaNKx``%(4l0#d^kn}+UQ&-aL(oDh8jOE5 z4`zk{j~yuj4l`Y0qkqX}JJOvn@Hu=k+{-b|I9MQ;?R(pgR2&cB#?6n~Qwgf^r)9Tt z8ZwS-y6`rMh#bP_xgvXQg9f`8YCP~{DLwvIeN3tE7LdV^2dJs=SD3IY={NiSJiQ)O z_4|YZVJ^gG{w@~xRVmzgcs)!F43EXK6NO!#b0+#499GC4Up&Pq62!rwL2)4Z+V;FS z?Yz@cZ#qg8+)IwT%aj}uJs4&#n9XtgBtZ_3dp&yiyN{v$v?f8~_n?29m+t#`DT($~ zIuV-=>kf@NhUE|@mQNy;7ne|lM1;uqy~Z30QP-zKX+BJ4&iOC&Wq%)At=cTaw!Mm> zXdigI89jk}zSva*C#5rKG*sMbh(r1!?E+LjXX~oFAb{sS#I+`!Q4+e1BN-m3HQ8tP z=k<^pQ{U-jd6gx?QXc7dwzau<#QC43b;(bCzewTXh;muTr}`YX zQoz3hCNmc+A_D1=e6BJ8PT)e!sKfyQEWX9(T?9=2%LHfiNMW29WCe+Yg;6IpuR^lW)jFkqF95duyk zzZJCIrj6`D=GrkMX_{KiK4#``2O=qHw0nwvoMSQ@iX!nL7}5786L^MiqayOX-vPhV z(kt{b-zW!qomQ)fKNUTnXQtPi?e5sYpdE>Z6oX$14#?xeq6VVtCJ;Wm3b|~|!qOgW zFvb*M>Q2w>k5{Y#nRlq2=5+Xx+0+}sRbfJaIBQwoL3!^FAA;_Lc-M8FlEaJ3>}We) z*LlfBK5A-oH982UrnkVpYweD`ypz`5D#H<90~yKSZ14&3mDYv99QoK0`2``)d#D(O zf$0o}9-Ch;h7<&=)T``bK{Y(%`Q8wpDM^{mMe7on1h&T++V4*QSCEswWm^Q$^sw@|8SAuHWs=ST2xk!%nmRnj3G zAHpzq_BHe!r!gFT(MAqnsqP4ZpQP+mBj}72h8Vk^-vfu~{+x<4jkW8p4|L z*=*W@Gi@t|zSr#Co&}GWznXRfK9*nauY3wOxc^LCx9uUGf`R1z zTd#0+(%gwO9Yey5x$1r5_?Pt*b6(8ENR-r$PLEu*KcMfi-R0@{$^pEC1f+-%MtL0P z_wxk+N=`q)R)U?LXBhN6UbUzbSpoc;$~%0&J3p^*wv9U+>LAT%YVat~*YDnl685c0-{ZMaLWi zG>E+&NN$F<9+v`ccY6Q?h-cl+b|3)YwZ*x+^F0^JtjG`yw9#(OdKE>=sT~;VzN+K; z{TY7Phx>B`%Q(;??bj6leJ7gf!vqW;-tnclc;2$@VF||{kP5$o`8P-(P-r~-c|+v0 zK<%p+Nh+7mA>j0ym=i_R@3K}kLm8?DN;WP+S8PZ;h^%ac-CS5%MQpT-MIUZDbQ?3S z^0u|Iw((MlIazr>R9nq-IXRQ-CigD_%Oj!8YZOwEv-3ZFKNM}6?q$}^#wjJIN0J16 zVmt8n1~OSsFVPwvdZhCNUtuDkM5+KsnKLKR?eF}AX7{NtQv zri*0|T!wA*Dip*hCIu$c9OI`z>Kwx+$+(UZcTWS@-z01WZo?mGy6!2rL{KW~x#RM& zD43dP39;EMz4jOnCve>nJ?L6~|MK%H9Z`E`6M1<)a{a;fIooUVeKa!pY)IyPcy}eP zYh93$&tcQ{lOOa&nP1d|&Hz?Dr+`Rcm)Ld;`FM zgTp$(`#he;bD3&Nld5HzG&Sxt;o~&lhT{YlSoOn z;KJYN=)dTJ^*CRIw9nzNhVfLs+47A9{b^o}KFvGyO{rTbJh?ETJpw^X=c((EWNK%sA|3&}Fj)W_*n(0^h|SkWW17;b+D)*!?&qMc5bdeJnAH(3atLvek*##_h_8a3{dq;giG z8y<3xmnK7P8xmV9X`F#NcgM6+H65H$a@9fgSC>~E|)Q6CCxxOc+ z(f}uTd+86%VpDD*i>K$@If{^FIG-=0-KR!D$lvj~*u^yPUk2dQD>_p`u=Wcts&xUZ zDxGgxN*va?R%-b;{;fl|IRjsO=e^(!#wsaeWD33!omXc9?|19}P>iHngXghFt&ipo z`2aB-!Tn8l=<|*)P4iccV`8B^A-teZ7_76?s{%SuJV$c9VFLmGs7L_XK24u7)jJ5n zeL>g9&W-+WEEQ+1o}Fyl9oHFm|Ce3Ngy<@e=fjTzzo9@cYDs9q8P09rL_c#pcA2yO zLD~~U0RzMn7T)&q0h6r4s1hUET~QUrqNK&Ew_Pt(RdYD_D(sU&!l0H z-ITR}EaKDO6_yA2c|Sqv#tO8J#}qkSjN8KyIylm+e*-?nzgF-j~kIV(}_`b7^ z#$%q4GVv;*2_9y4+DQJl{nnJF19_4U+U} zdGx%3dRUTc;id_~R9KQ8v4X)zADF7dcr8`>dYhF(fUH7**D^+#ow7c7L?3g`=E^co z^!9fC%|Oj+Lc{w*t1KLj{3xuVXdIk%kDO$L^jaAG&oL*9MH1IJ2&z*wIjB%0=K1Fr zdD8(5Q_x;$kb=lrRKLfZ+65bDW^vyVjqphk?qqQJ2($WLg&u`0#v;PbiIc7u(7-I@ zwcx+um()oq!&;5chsVHz3I!^;#QN?0o(G2AuH3mW&_P_rKCs)z#X_&cv}Dqgp>EWm z+0HzV={W#(>$rF^!WKYd5%HJ8ff*HOZ)`RJlu-q}kJDl!5FzYSfhSS;diR(2mf7 zZ*|B$k?zorU+yo!cdShA43v?aut37Jv>G~9gS!!kyC#eBRH#-Pw^vVz&*S^5&6OpJ$WtDhF!f@!~Q4t4B? zo*r9KQxbS-=V~Cedit1%uqea`UHW}Ds!mU6-`R!Y0?+b&iwX1o3{h7;=qUi@yMIDI zWm2j*6uxmt3lKyP)vpwh+LORzj!F8z5_6!24ClKYC3_lrZsTnKCO4>cXP2J2fMum& zmD=%_h+HVghPPH_2VJpJkq1!{mBy({j%v}CGc06m!B|SAc5Ow5gtECP@n+b6 zQmAIErmkDgm+^O2>0>$kHAQV(-dg}06oDDpHLz41S(yW6M&6Pu9XG3l^=$TD~TGz?)hRk6@R8$>G7fQ5$ z(K*Q`R0NIGNZigMoGvT{A1a^21X7smhDVflGc^PWkxiPMAPU;dq)bI#3P#leU3Hx? zvcL8WZ8ekit3}wiyqU zW{;xRnoh4f7MLBfMRjloibx>v$koZ2H~PC_b2&1rIsn_{Bz>okfZvq^r3}8mtt*g*+ZNa_( zWHiKPnO^wyhskSu(pZ9@k0py?{EI18B-d&X6q`yE**LJ~Ax6y>Gue4AkMol<(qaB4 zEKaN2P;5-48WS7vQCko3tqyLl|Rt#MtMGILEh~fZXdiv(YWDEGofNEq= zyZv5~SY?=)xXVT&92wGB^CTBP{m5j@)2cqC0t>xS8=Yg*yo8knDu6|eIbJ6rG@XWC zQ@4QZICLaU6~yhjzu#L;eFi9PZpaOCpS>P3^uL`H1O$Iz%Fzxbr!}jWhEZ+GOeAki zG(zLia{#YYGA2X8nz@wNa45(h3N5kikwKebhozBDmW2%z$svh_h%0GWqh$jDp@)31DN@!6{ zWhCX>sZmGA3fpXQO+c%G)+)=BAY)7%Ni2f|C6iqD1m%$*eFj%r4@jd-ut3pD{*r|b z7R@4x3bO>RaE*v)sS0w3NrRU2a|gp1+=H_N-QTQCkk@633i}Ps)UGTFMJK=l=MAGk za>5%1g`~DC2|2BN)DnL@tRFa@=lXn9bw|oMVm6XfvKtP`D?=ff`fu>#wtwa%N0KL~ zf?6-&#v9thgfy6j#+2Yh82{R9$HFi{QYr!TaAv+_|Ie*h zp%cWd=K#m+ihFjZ%KpGsr#o#{K!|u6jnq4IpwMofocK<}ritL!!IT)-i$UKD0pB0c z9OHvc!T_U&=A@ik1=*zAm5zcXnN3rIXp;8eJ9|0H>l<4(U2KQ2lJ{Qs}dhG5P9a@@;}dlW?x@fpp!SWEz#MQxm*cv%9XpyO&3}-C+L38k;8D+7MP2 zG)8-HVa}*z#2iaDS$>DahXK2?_ubBinE0&zK#j~*4qR8M3mIT@B(XOi{bq6M5QZH7`$mzC86S?p1A`=h_=}JU5_uO6J@i~jMk~E(wor#Xa?C6>sgb^B zA1dwQ)hZI(vPb}%*X7GO_Xmq4Scb_>4(XMn)foi?j-<#3B+h;b2_!ZVPBcFxHR=t4 z7zAwjWggfS?ai*`AM%{E6PqZ?EigPPsvW5EMg04}d1mHUJMaQhEE*8>tZ2Zi!kmCN zSF5{PvGV<|L`hU=lV2hH-r-h@z*TSxEu-vFHh(58T6-loU7zTbL^8>shcl*%0bv6s zBhzHrjHC9m_9l+1jj*i(R*4t*+@wN-%uB zR*&CKovim&UKn^F6LfZm=y4G zD5W+opqhvOoRWr_FampB)gYF5a#Q09r7}sCa8!mMSfzE9f1)0z8k0IA_V8~x@?t^_ z20LRq!+dbNX-jsDUF-sg#RnIO z>~#0w&Nf&7yfEo64~h+pkwbc{F7vt~Qx{0O9;fB7$QM^ihFGZgHNo5_&HaU+3nS8I zZs>T{>D%+>BP%rOM%X4?#2O0yYm9m>; zjWgRki6?D+>Q|6Yye2=J3Cm~~o<5i4r{cdnX4k+#zjabH=yh|wj*^|KEws3D3gSOt z1T?pNU_UFp67xKGF7|}4H|7kBP2lkpa)Ssc#)*yI|8SfTu{BB$#3@J0*Rfbh@gpap z%b9qQA;^JwF>tG^yJ?{|ORkw*9{vm4OJ-i&ff`Lcf!!*)G;z`kP_tkDoxDLhG9jGO zs>;U`$J*S-pMz=MGR>dF=jlbCtIJNo*}Cc z5_1Eq5))ao{EGK0E8(xi85Lh);#YtmoU*wrhKAE5o8CiUnHE#X2B*2f>ssM z96PN1&Ym685dZUXW=g;2?{IUS94IIc&bntbPXk2dX5h~LhCp6xM5ElaqvHf&WuJ!L zax$Z@0gkC#+z!9WLD)rLZb3b|_d1KDG5*MGJ)npynDE%;ozfSS!sW!Sh{$~p)_96Sa)LBIY1GTU8c{HeoZLMB8;-scZ^vM+ zUEQ|0I#rFkZWmRyI^sm??5>oY4N7!8i+k!T9+Y_^I2YL9pgq$XJrwM(v$0L90Gl%c zFkEqPb=uR#;*Eqr2XbJi`GUuvXd4j%*9qbeL_Dhp&|HAX%J0Gd#o;9*cy-tP)Z`1e z4!FjS1gA%x5CIULMqSjEOemR#ig&3}IV=v*6as`JPI)qRo0JGCq5&-1Cd?WIRsE-b>PrZG73L<*<{3-6#pA-U;ExEDwmMpQN)L(ITd3WrZ#hMb5^O&8a~ zDglft!bzzi0*Qq&4jXEXiHDleBQZz_$uPM;uVh7PDk%vV>;r`TeR#>;9A0KmRU`A{ zT}+&}oDEMn)e1(j`GN&G| zl5rmW#Xtnr`8}XWbWeM!<-t+%*=Tb`GfMG}GD`u7!JznZF=C2WM#t8exo6m z1*IA2hAdqI=D2$jsWuc65NTLS>7jtbhCz2seZP@!r-o)x93zI$>la&x{;xY#Mg5W4Gm!S1 z0r&dF+#03m@wTJIv@K%%99!A-&} z>J?OD{oi4M(zD4ey(X|A(fXG|VznfuzoD1k(`CLh4!Z&Hdp$jNt+8mq;gp90Y5j-66op4;Zq$c z+7mL-k$#uHlYxE@sc7IT1o`U{WH?tvg1R5|vS(HslQ)Z8Ubr8O!RyeCUt4ikZ)Tai zW~y0Ty<-8D5U-DdX1bGKoz6JZ4UI9!p>WNqG1I2B5UZfvi^6+{}uq6MN@K(k?8r^*{{fLHal0T_{AEJ zlqX-oz8ohc!$eoJbZ_@-X}$Ev6hm}VHEqmx(Zbjc{wTA0-d;>7B*%m{tua-`Pj%|d zN#!H9807jX+($38-3l=zDJHXi{p8vG7O@-}Y?mtZegPrC<>rMfrpHr4W43&ea`^}b zFnnkTbg1;vBDg}+3pvT!();J$UzhwU1z!cWo>|Co^KvOedBo-NOMhgPkc2hw#z&f* zV%L^>>ws`t)^GSCv_V-(rBjaZ6NANHWt@~!A>-)9WT5nu*ey`$b#-6rHYFq%N|j3- z@_2u^j6)m>S}A^bzyVOyK6%_bsdvVELF0dDo%RjZsbdOYgsxkjyZ1k!a^9Qi^fQt< zcbafHK0PxA_!xQAR_`?B`<20^Z+4d3s8n+SS$x>}szZj3Nh4%4aE_%e=xMW$gF~*jR9x!-YM}E%)rMosn+3gYkf@c|EVy3sIsRXBxp2A}1w+so!{zhWjEG=YXR zSa#7tYSNhhN1Y^eKE;qDGZ1TUKN>gY23U(4c~1ZDK1cyP)|2p6L*MhK zRS?7smfHQH*OH$ApNW7ET)!1HXr3^fDeN2;P8DL;Q5*?qI`Fum`5|W9h(J|6EnY{Z zY!_f;@0z3G;$9**K5TRNXi^ z`6Ei^rlPI^2;}$n-!sVB0$?lFuh8!-PGXCV?kw|dNyQyHB8?>sij|9d_mnDRvzBJC z>vKQqyM90X)XjA!E`>&hfru5ZFktMj!JV=}I9;gH1(W#C6exNmvldmZ#z5zRQwtfm zI>gCRzo;{X-mB>s!AHsb3+HlCoi;B^Eti%Rly{nY^w|be{ezuubZCX1Qp6? z2#gyvexBAkbkrNN^C+f{GL`hcQ{oJ_@7*kb(9MJ5?{u$q&Xp z06qPM(D$q;PeY%aA{`)~v79H2%`9*Fv!iAW15PNopcfmx83x>|QWB?~Bhe|riavwd zg5xpk#JAlR~MSi;N6?M^3(OwUX#3%Uq+oD%^zVmig zqCAs>>B73S3hp4N*jG*hd*l8>p2Qq(8>`R3*a`WY3!UKVs zV|6>1#G-}~X2hGd5OND250Zo1&@a5HaVO@*l#wHr&1Qd5#<{%*m3t$NWLCHC%$!$l$%^_ab4=|@~CJ4tvnqz z3pDeytNDJ1kzU8KeMM-Q_i#VeU=TPzI)ll~>*cM@+_des<{T(11g zz@ih51Pd_A52*Oib0`U!(D&eM#F~l~ z)ohtrRkJEnJC^TeITm)c>Ma6b-ul7ac~D4h5o5@&5~LW)-u@ zy$ysq8y{rzFOmC?HHr_M8Y=}{wd2B4Y~femuew$d{XwKbEG8ArIumAQ%(`I*q>zd~ zodOB_m(HBN&sPBLVmvBXraUftNUTWIO1XND{A#4ElNOrzz#-M&&Z88;bUNkHJJtWn zZjVRWr!c|;9F@C9k@mjKI*4nSMMdcwZUjco2@pz=kJ%7&13;wXM%?*vH-#B zQpgZkTAm!Cc#P6{cTA72VL(h-lE@MR@1yhxUZ3?M#pdC#nw~O?Ikr@e27Sl63G0el z&M#`4J&N;qj1|CZf&1oP^8;DXPf_&Dsju$Kx~bEp)5H6Qe}M81b< zKt+%dG^m$i^dzNhilKM^cjB1eiSuO0e$Hm@Ard7twvt70x%^(5gNvyCY$o(=aS+~U zc`R2$^CC@6{kAE*WoyzXq?2{VFDf}o$GL<>Pam_ds-3Y%qzC zChll}hUbbAAg{f(ZQv=pJNWo|-g=m=sh4024T9~To|~S6(H668q8DX_EjGf1`~^ya zOBrt(8XV5g6m-5Mx95(Ht8+lwZ>=7LM#XHw3oL|{!j@z|K*G(U#>#(QaQ%d3rFwjf z7a5MyyK5}7$wF*3+U{R3{Bv@IW?rX;ICN@-069%FmRh^W&BdaGnUnLkJ}0S*rCs1 zKaAvBus(l5iogk;#`kDH_XQ}DT+&qr(DqJUA@MmMEuH-$NQIGW5q#chY{8VzX+_!f zcpI80$1ZCrWixWiUbDYG9*Luc2GE`Q%~KE~9FbVI^L%fC5=}HuS_Md$=Gg3-ph@5i zsHLsiC?P5l#a7^r=?=n%5p3s#4CKSTo*o7kzn?oq6?vQiN@~B(Y7)~sKtYq&Xl~Gx zAB3}{+@38Ub!DvoFD4Iou{-QLkotMH<;%Wh!EYKG*3((p z8Nh~{DzY7JB1&25q8BLm85fF9_9u?6PEZ{cvtDcm22(2g{eeE~JL5(l;Q`=wc3 z!GSjAoFJj+!vG(*i>CQJaLua?z;pOdRVidVx4nL%?CAQ4l$VC^?~DXae;V8MJl>JE zpD65-L&A5E`j3AR`}09TIQH;gR{TH`x`-gpu9dID#l!M|R3;@6C8PkGQUv(sE=mM~ zV85I`@COVA>)v+1-i{M}0^J)Xp1!}2;>UA%|HRSPJ8U!*#@;WUeaye+X9VVs& z=gNS8DdVyY$q+8U`yd|*3D2lFBDd46!i^WJ4de=5fo?9G&Spchu<}Y3?I-0H;S3|? z83>LC5pPUU;UO!zj^QYRiQmEV0*jWGcM@C1kZJ+n4{N=V+b|gq?UKhUZURZS8&)T@ z8(Zh4ddJk|o`Ljfo~!#7Br1nW52hjRk%uwGwF{5?{|LOrl9C*H%1j3Wen=MGxo|SZ z1B0V9QcF&G6^wy-0+4Fc1ta*v+J{sSFUd&PSugp90SkpE`wq9$&uW9=8--xhs?LXW zy|)Jvl&%eQf-fX9cLd(+)Bf%AyoX%Z^Bb*gujxM3xvK3v&D!l!2v!}xKN^oE&ewx- z(}rdKeced?=WzN?OFO>Q|Jq!?p9=Nor&x*$m=dXm!&XS|5fazo`hvN_ z4G3nnu@*OOpVLbu^$k$Bca7S?X~}L9R5plO`0>VpT289I{{(E#LgaUv zY3tbUSN1#?zb@IwzP}(Mh0JGW^jH5bm-23SYQdunI3 zmP7SY3bPYId?e0!yn=ZMbSrW?9DAnj7*c=>J|+n>tTTBUmKsf#c-$_B=g(C>LU71L zOhpm;*soVOlPA}o@=KdpQ`D$flq+0Q=la$j+Z2$myaPWel_@W7W#TmiQ*=ztL|LxJ z5ju2UHw{wfmh{JfGO#k3>$_GM=-Piij#wsniepOD1*@w+-&?v^sB6spwg;UKvy&ol z+E<LPuhrCpJyUBsb3G4Td9v1qdb&BA4W5?}zUp;XvI&O9 zy9@LWphd6UO@^++IpTHbi=HUpFbR(K{uoNdG>aqu4Tyd3@O(pk%%slCFk=)9{${8w zmk}ih_x|+y$a7n)3yWQgy#pG+#9Xo+mA)v@ni<-*AKNfWfeSB_hWnpAW}1x)^V9_1r3DbwC) z;bxA=c3cssm6Me&R4;m#QfFo&Ld70q7?vMWwK@&Beg7^(Wyt%ai8xK8`VpXW(zW%A z_ZZSN*pVkasG%LR>ubPvWVGg}aY^{dnvv2$k{Z00+zPLOYg{?PIts*ugWMftL1VCDztJ%k~qfdPrD@&9@Xb|;u z|2zwxdCP4W5l#f2ztuNXFR*Fxm;ClmI-qdUfA5)lfSkVLk>QhR;3=cItlzfZzmS2B zexIs~N&%0a(24HxA7ca9Fms|AO{5}Fyn9iW{-#BrxsSo~LFGB{QR^|8Al@W+i5Xlz zYSN5GDZ=D5E=sCnVIHA#`3MS`_i7}0BQ`k9)Y|v~v2`kn92zm$aIJGy93(ALmshg3 z$Yi~bzizV?e>GLE+@-8US@&h}qgrac?r+bZsE?;!yeZ*e^Me7%?e6+shuPcRmL4^p zP$L`V%E9B4YMvaim)-U=EZq@Fq9^wc=keMWw0;rEW27m!z6x4$<-2;Jp=W<3SOy%W zD0>^|cZ}yMo@Toig8B~rAAvcmCnX7VY`d+Biu0cX0@ z3etGB5Y-jKAbS$*We8y%?nFg2GH1zUGmbyxsmxS?p1Np?0M3~EjC7#I$|W8bjQt~b zb>*kc=Lh*OqM>U~TJrJcs&GI!X8+g3cwb~*P&%9HI~GbIPiY< z&YK7NzfD(liEEoHZicjNqPTy9+b*4jG?QWHzxINMPs)FFw(F*^w*Ea;A(;4k6QKio z;n?)`QSEUq%p$KMIH)4cLaz9<*N8lVpqd^a{uYC!R$trhu+{@4_Zzl$QgsLAA5;Hv z#_bBj+OeC&NMSw+)uIW%Na4#o9w_R613+#~5eXRI8Mhnwm-1E(I*xek8@_birKrAl z7RYrE?CUEBYia}f{Xbn5i6pPcFRAn-)||rg1P|oG(`K`O3y#X}H%fBr`(9{dJJ{eI z=XGBMwmd$l6CnB0Iz2;T*DVQgfT}O}8~S;_5VX7s$)eK~#smbBOF4dc#X%iAz8&3f zV=xZJ&!)W48ev(70LI7uzf+ItR4v;g~vOo(GNg|X$pp*(m6hG<0rD2^4u8fSxl&4?1rx zJ!7+rik4cux|xASs}6(dV9V?;GzTgbr{WnnJygCLxC;9Gss*4N!b{k?Uw!TPAv^T1 zuHE;YqkvYgYU1N4J(G3I@M1wU_DIQ7%)@q|fb z2o8@Z8rmp`!NfOWuStaG9}xYtNaJmyGBm^R9FtH=cLDcXoVm)I_xO;Ctth_;cQ-13Y=LYsm8kO%PDe(| z+tBbsT*mrBI+k4x859#VrEL-$(+Y_r)&!r6Z49J3yLa=W4XM>NI1qL}BHo^85|mlU z2|WlJ1HIEaqqp{iNiqsDNf6ERcv8gY#{IhRN9(ju7W0-e(SiRov}51N{a*o}quuWc z*@Jt9B_T;~Ya-s?vU5(LwfywDkv87I`)?$F?GF_+>XI#dC#hqO69`o3S8HhQ ze$3M@mqEs$YZAo+yGeW9enH=QT`zVM`?=lz@d72#h0rWQ`nEs79{;US#`Im9BB^@{ zuUkuaK{~KZVH^4cXKDqz0@ncU@dN^tW|&j*1bD#OK5g7~`1`wcUEt|SwPG3ut(pB7 zuwvwWy+3%@hkoOi2V)#=S&V|c6Bke)0|OlC#d(>J5Myv2c5I8Dg5gqu<8=pzh440C7dWM?)7$~*^CsVJJ>5w>Fbe9R

6K=Dvt`LsF-a0EIn$trV!0?AirD^PHR9#&UFf4CSh_StB#%3-7@J>AX%#O^&$2JwX@a(qFGL8Y` zj9OfeawRJxD+~Q&noF3i*{VT`it7T~4g(4Zvs*e64w(41Tw)! zCsdOq&AWEf&!0VC-yKWg$yEM$WYs+^J6Xgm4t(#tsooQQcqD2D*hXn+Cv%|Z9hQV= z+UwM&U$YO$Cgm}i*S};f@FZQI3OHj_hM7be%3 z*KBjU@pZ^6)0+aLF*li*^xv8558`ervs|C(${DiN)Pe7Hq$5D?*qWMY5kkU^xAoVz zBs=4zP^rn&^Uuh{FLDToK)TW5c|XZW6PT8Io6{wpj}G;oSeG~$O6Z>t0&uSf-l^4L zx6vJH2{o7{Kbo?bY`V+i5RFQW5d7ERCY-+-kAUJ zF3J4q{T#&5!Q|fx$3m8Sf_QM+q^M`$$CGu_ps2VwvrDHjtGsKc-e9*{vyL>cua0+! zc3WZ90OXo{4aJh-$)@!>e(t=d?CH%2%DuY2rjW6!2$Wc2`?5d#g6OU86rj=Qv-F#e z`*n1Ru;lUpIK@_<(3tsw$LVIH@QZ%`o>8N9 zEM7*NXITb6Nci0oE>EhQ*P;Y^D|X`u;g^oBkBN~1@#2X9(atwn>=$WlVO)Z}!1eWt za4L*P{?~G#+qND=%TUH_bF>;pf>xTQl7y;)9&WTP`=m27dsb;ky*#|RfhXH9R)?oW zlhX@nDXEX4v{Y0|9`R66P%+&U6#YNO(LRLu_!48myfN9lgET~Iy4o6SkBbwHz_cr@ zyF8m1@)5*i(EanLXs#9`LZ%F`ATblgkF8Wym7RFwm*U1ZMjp3}LRf3dhP5P`85rO# ztf*PKR?M_16mv;1qt7f}UWNQpfMtsya&7XITmX2KW})gO0PlWwALkQIjgEeCCu;i% zGZ~NaN0m@TAbD_Fqon*x*n=u%OCQIMBeI&axO#q8UEa}_z`6r09v)LMUmrqKqm_1q zUJjJQt-pw8x#i+-o)tCj+q;`P>&Jm_rqKadcV2yZhv z8;zD+nXl795GhqbWn`SNUY&CUNc}p#lIuzE!B||*g~-?71W#`!X3eKh8i(_14?wHp zVGpFEyRp)2i7B+O1Q|B;i_McU3CE-c+LjKLaP@H#Ta5f|h6%on5 zG$L|EizQE>Q)qg6loWW8>#*+eXl_xHNnVRV%_)V2d_c6Qvm8IO;%j5;PF~vXooQYT zlrqsM9@lwh9vKa#;~g70;G#O4PmiVgjQ4jm27yK)Jq}zQk0v6*`gucA+3l@fyqg0J zn*~K;C}q;gsonX#Mr_o2F%p3S_isAyZwvlMMF_mNXKG<#aViFN?#9ZLnn6>pFv8bY zokJ0F6qZ|M*|gWYoDxLRH;(00-Dv#T%C>rOQ|yG>=}zw&R(LU9h}q+0&zD-PeY{|R(pkA{b6KeSHy2Y!>Z zwp@I|wHwKo54Co`4q3m7cnQ(L_<-VJ+L*4{2JbqBoSYB&wk9_zYYK=29NCM+UME_& zn+K$%r6{Cv=*;sr>}D9{(lABeIHKRd@KT;}l92(bIWq$LkcFfa-qEog2{ZTQv9-8P zNE#9VKJd&&?fwDv*BTN43CSA%?s!RY< z?oKBovA|rBgaH^VGXJLw*4oixMu{`qRo8r+!HaONEZjIY;UrKL26HCi zJ(7T1rgPELyl1T>7Hd~Fu z;Xw%%+v0Sazy@u154jiD<%jv%1fg$SA=a+QraHuwJk{&Cl{HJULwyAb45f$~T!3CJ zE-B6KZcp5fn1#iBH{;o1&@Rx+9f*kI$_+wJV9S!YknnO+632#q!*u}KaU?J}oF=6a zK|=$YGk!12_d?{5Nj`#RW*ywveH_NWhWB!0ymjFrOJsHDj+`*?|IBlHz4j7AT^1(B z#kEh_da4-d<&Vj&`UU)ULbf-&drl}aIUn|bq_`)KhA_(KJkQs3 zvP*tgfCs6TSe{r;io){+)t z$urBrV(gtdjLR@Z_XBqVf8>gx0|29GD0RP?GI`i|w|x9uYi}1GhEUvLyUh|$00jNj zY%wCgVGtcsePRQkAc*;N%A4JA4rvac9WPc0(7Kfgaie3>;&!(z#xP5Yr2pC{c#AgN z7e}-lX2r9rs{enx|gRqe04`5l@g;%(reRvO7qM& zZ&B*?j|D{$@02579kn_E3wNP4X+=CB<v;+o zE1OhE?bdQDRY8*Y4>7Lu!<@m&J^3(TsQUT%Ht}X7zFmZ$Pqi;tr@pTWG{RE7GY9h& z13i5#zv@tc-O&Jq8=ne|yp}%@>2QM0H4SjQs(w!4D8a62&Gs619elSK;$ezpgmYNA zaUlbR9a^3`tp_|(g;;@+7QAjkitaP_Mf}qBCRtgN0GzKFbCF~ZjXlQS>_{emj-hO3 zizWJ#xC;?Y>FJmv&}r`-m?edrre;iX{5Nw0OOv9DXODRNiJkWW^=}=z6=;8m7>Lo- zaTx&s)NFF-dMF>RnfT@Id8doy#mK1S`IMRlP9iWV^6T(iMO<3|P-EHurWh8={`_u% zm>UU+J}jS#yktDU(3Q;{dSR+N-{J4xnz*9T*yk+f2$~zwq%oKH$YC^*)C3cZ2ni_CZX<7p*1K(J)(OagxfSq87HZHWSyC7D z0f&AipwEJMKh>a8h8PNDYQTOh%#%swx}BIr*G$J?`64B;Rz+?&J(n(t?agmF<#M7` zy##|3EQ03thRFfpg2TH*)3Qj1aXA?oNtCX?N9c)-kr>wAI+jSq~z%f|GfER@fT{CnBNH5&ysX_&$Px+90jti35`py=J0-hJPYKz{fns(AH zEoy`9k*UfQAp9Xtd73m)n~&7iNROY<7_027TpqpoQX6irkR9sl*z@%QpvVD& zxYxSxwPtTGx5zLiDFv7A!P0Oz@xV5|s9uYN33a}PRRdKum>p)1)R)LhlRN}V?#y}) zKi2)dvo+eE-L1VdRwn9C8`=XWov#aL*tYi%t=nGV9)O3Mfu0N!;paB20vZJku-Z^V$T1=g)el zVT)0*$}dy4y7=Qd&TS_<%GuwWLW=dKRR|rf38yT^eoBkotcbhBFy&lEX1*|*^k@*2 z8mKQeudoV_RMVw#%3;y$m|}x*^Hn)L8>z?E$8Y!7)4Dqxe$>*@!ZvPcao%6ZHwY@W z9;K$HR%>_i-vfj2Eaqc<^A-SnCXkPm&`*I21+{W0>fz^SZ@$Us_UL%Q>iYxgFK{nI zsO0UK7d3NnEtNbulm~4mc1AF%F0QT3lun7mVzHZN>yCUBTEeOh8f@H8-vb!JQyyNo z2aR%6x``!OXsiM(a76s-`bzZ7ouWQFI`){16dvOq3h}xu&qQbvW^zm{hUE zX8JSl=Hn-7`sa;wEj^CSCWx7-9Z~qEUDzhedQNaM;;e2+{Y|tz()aI?UE=ZS{&myxN@t!b6{`cZ@VO z=Ztk6i(jmOu)V?mIP|N86p&!I0OMtmp`Q$x>aIQ@2XwN4FC-S!wyGv|D&DYq?e>cM z)Yp6IN@ycuq=x`BJs-etkhbsbn{=oL@0B>e$9UQXnApR5yq^1p)$NM?UVChl&4+@e zY8-UexmrBm#RPESd1Dr53m%rx@0#f}R`%m4Hp?{Z@Ylq0r7%DH`y`)^r!O(NB5vVM zoOYhbOPoO4(dp?Qw!_K}=pYeFBoO|`&>9VBNnbX|`6$Ac(or%;%Q7483=C>IbS9@4 zMef5`#Y39yfAfDJOz5F1tVnI0-5{@w`fGPm3VN4L!s)d|5&&nVt;(9M0@RFu~=IRwWualDkq+ujw-aP?&x>IzsFzrGFTP?b%U_&1WmmmOMd zYLCmdxU@ZW9%nqJ$h9=i`IkUxVXVB08BCV112cI zi*G;y8Ig)(qNi5h@>6NWWivB7bv{Tz;)5uiBM=^%@G>*ppWu0$Ab^I}2m7iSD24VTBR=+nDL zWWsEX@aJc#u+p2v!cPGTT;R|O7$*M>{{#OLgb18!yD7`2i%Ahr{%3jO<+8GMG}ZHa zCS5|qqxb8z+rV5E;LuOrz97Z7X@;3C-%QzA6gAAH58~a3h2z#`&tLU zCB&EXy;FmvY9CxwWFlGaVaRar@%HnpP5J-%v9q#Vn}&?K!CE;XDu40H-qa$B#~p8$j{kI#`-O_wyA?w95w!Aco(p`yI>x+27#M zoKyEtcXx(@VD4i+1(S2HKk^gzal&@i+}WwdyDuj0HKO@uBptM5jN<&$@QvC}uq+iQlzeTYMmWI?57 z!d3)-ptZGQ*Qv=d2F0d)p&Pxloe!WLMT)3P0!D-^n3xzN-nW4{H{LchvE!90zcjv0~i&Ik2dGL zkFd5jhNXB^MG|6xuCtXWW127b-4t}I+p5ei9s=LG%hEOZ!_ZFXX6uqKA*EE=*t0Pq z%}9JZ+WiI61s+31BnNJPF&#zz5@VA6A|t-+_4>`Uwjf+YhX@=xhb-XVfb%gW%EZF? zIGsPW5%Jf8rj)~5WScyFSgLC^f-p6;fcH0|@`9UU$itH0Di0#NTvX8*|A}d6CyoI4IDBO)ufzp3qbVk0U)A8T>U;W<;k6q#S61Ya@P;9u0%L$i(gxmmb1h z+%Amlz(0H7NpJ4WI|h=`Mv|MTd1{5$u{uGj`|s*m_gug@zH~XvZh(jRfapjVt#3x4 zuiI72)n3CAMJ7QyodDnD(2(V9fQsXXoUIzA5fUco9; zErSGSVdkSq>*K(a8gv?TTZ zcmj0KMZsO`-X{WHaf4Le&AF67n5ksfN{$xc76w8o;Krl7dhKgAoKsP1aNTtCkck>>k=)u7pCLM#yzOZgz7AP}u&R>aL< zJBoe$T$#albhJgqD~&+?0*&cU>hP}@ZSNzP8=dC?etBR7LYCdH)&?=Eq$^Pr%x}4b zyd&O2VF^ePaLVNvN;$(qG}MBEegqo57`&HyhIzlQF|BD#R$9tabgE{z3%}R<`@92# zYpQrUhqL$h`q*&zXAEZY8;n%)wBun)XaFYsfp8GzRA1PZvnhk!&aZBd<^ys(Aa0lY z2ck+bUXK`}rIIE1Lw$%(u0LPxhj>x=0W3h|Y4bKo?XSYh4D#rBl>d$jVC}l-@K4gX zt~p6wMEJx-FlHU=j~4pON16&UuB-gBU>JRwB(ZcBI&Za|oT9t%; z*U90(30ng8+&e49ueHvEHu8kh9|~^Q<;`Get~h zQc_H!(nsyiM?VlzG4nzKNZW76I9#@x+$(J~8N4rEMox}Sj*c!fhyQHPru2!YFXz#2fw65n6aPiE(0jlxi6zrrz;~2_FWA4CS74v(>ud zTw0P}Kd#W-++5FpvS08|cq8}C-s$<~#&pn2JLOXen5+Ik_u1$JlLMRYXRsf@Cy41{Il+%^utb+tVXQVL3-5IVlHtW;KqC=Ich&)=jdns2xgJdBPEPKc4+1 zq3yQ9i@oFhnj zE*E#0ioPU?DHs`1a4zag$Sg60|=pgus)uTtu&W-TKG6hDB!g+gw zKy4yVfq6r#YJ{0c6)U1pMb4JL*Sk--=W_9LaEhyiJWv{=;<>f5QoA+gU67#S{`Pjl z@yd(Kyg;NW!^LQ$$K#cCacOAYv~+%v#PfdE!>>%JySpXN`%Yb%%7;a!cvone)~=IlgJCW<$dzGDn^h_CCh; zsvqNwa)rPk(l+ywe_u~lJF!2 z%S&Gsi8xgCJijAgPkqOS7W$I~`*t<&Ae&$`Th2(?x!tc@1T8%*n(++Pxat_pdY5?O zrj-+7iyBUV9p-*-+J_cf=fe`w=z!~RYgO4uOecrHLIXK(RV2I?=N{^U-z5sYwf7gL z#IB#zUe!#Cg%Q816G0q~9ojaohhviihGRxKQGU(MzTU>WOoEnfxAuLlD36$i#u(B> z4P2D5oybQECsilUd~05r`|LdKdz1be49Ec^R_jJ&H#2C6dBMU%TVxCP#PV(37b#15 zQzhT04!*~fEH`flC_BFIb?xMLVmY;5jw(-Yg!o9@MUj=8i;jghQUOQf@$|y#23apb z8q^CybHjmu^Xd_2*#gcSxV2O}kCz-m40N5jPzLH^&E}*2fZB0GzG%F?`T2QV<~BCI z@lAVVqF{de3)AR{R$_;U63zOWy#+TN}ADaG}D4(0rv9hjAY?V*84ds}N331WwL4Y44EaZj*`@6Mu~^^UefmVf$5zb0fubeYsrj?_HpPf-TRF@(SM=WU*01xrsYe6>DuIZc1> z*KkV6Vi%T}tw&b4T~cC3ukG@(|K51*gQMAE3=adRTf=a0%GX&+)J zLlpg(VT!PP?ZpVkJ?wdwcb3H^)758M8)=71-C7yOztCmPY5OlnVf(IHP7nQY{LOl_ zqZOvTvb5~GN^|IEf1!Ka!lPNYJaF;B=2^|TGwUUA-dh#%;6c2DuWgctsA0kJobj8q;Jw8)r-W0e|>%<#EK0^qDRU`z767wZM8%qjck^kAX$>I2Bov$rBhufN(cF%*ipfDOtRVOkW^om{{ zPZu%Xty~sc6Jc)A;oJoNig*^p$m*2m+anHsN1%1Q)P5g{51rY@xW73L{^0{Y@<(Zs zLa!$h!#khVI8Scwl4u7QznuWhbx3Xp2mOVp4DFGqEI9BI5g+^9PDNyn!1MC z6Wl5dYTiWe+rY78Q^!V>Gje$F;}Lvb?z9JL0%jdCaATr4K~Oa$@U49E9zpuoM$HV_ z)@JE^f{e$Shp7{4YMiWqC#Mh5QQi{}AJ0}t`#hVNDDeXyT%k4y2j{@6zV7y7_IbYo zLL=DkBjgc*6#rcwdNbmj)rXaKSoSwZ0* z=r)0^co4t&3ru00YQU*1pRJ9xr?!NsY|Au=1Aos8;&VjF7Nmni-6NvSqvPdSZ{b=7 zb8dTnynHTqZl+vv?u*cou># zA8U_7nlhD?2jeB?drH<>bfU)V`Lw=Hpuo%j2K^~$xF}N+p>vi0G*O@SwdcA<$-$RV zBOLbZo}&V<66?)TYX5n-24UNtfw*kvFefV&Z?D+wvw`lt+X6ya2@LA7-fLP|MT!sE zmL`E{D)AR5|A())j;iwA+J$8i($d}C-MQ%QZt0TlULYYQ-5?FpNJ@i9r*wl#cXxb` zd%xd%e(rt78DstN;BX8c?lte3*LBUQ9JtoS?q+OpTXeFT(d@g`aYO%|Hq!gX169%i zi4W%m1yRs`xr)dFmpu)b$vY4X68OCs8cVXbwnK`nD#jh{D;$=!C_E6z0*uIGLDzdX zqrCRIsbS%u)$N(yEw;hmu6RN|@uSDKJ8%103dxK>YEu5y$aN)y-6|-1b#reW-jIlN z2Hc1UZ((Dre+PmhmZ>=Qe+0!6w9~8lbq}`BDnUA(NFQUzOWwhxv9xb24`ZlLt;QZ4 zkLL#D*Tzqrc)Pz3K4z~p=7n$Z3}Z`n#0(20U>|l{uKwYN8cWFAGw?k$=+<^zjx?;4 zRnOn@d$%xQP-|9l{KR5tojQ8nvS|A?Spya;CIaGXy>9@aMRZ9hwnKZa+;PwS-mXly@tM0F}vqY4i;skdKCABQs$ zHo$Q}JIdz#Zf2o6mUFyT1Xl$@Ec^OD_O|d@^?J)e-8t4HmYO=B> z;L3;j>zb8IcLyjJm1k7CTz4+)mX6w&tfzavJa|t}ShA#JeMv@AUE8!%EXPUxMi|?*9-eo!6T@r7UmqzsZ zAPXT@cgnL63Hp=vemJ;UlQKqwOQO+obCo|?^Yz%#=I|MDbsPCSoDtUdU30y^e_?59 zaCWs^=54g2aAq{dRfQZ}-o;aVYt>8p;V++Q&mV4Z|0g}OM}vI>~=QAmh=Vmml{exE4{Ek`!j!j zC{!N5FMkNC(iZE~9(G&(ZSa!R=~w~8nSGhF$G8d&4Y+d~46Rz8&W!=@U}Fm-hpJ?9 z76C)6$nqAsCwW{4NA)0gZGKQ=V`Cw$Vw?L_cvI8$8MGkW)d_P7b+;)Qc8uyjOTWCt zKfdmHzGM>>8V9^|Q|jM`zxOzS(d1y=wO<;XeHLvKEiC7IMNc0614GW_k60&XTLu&oC>^_ z_FKm6IFxEY()!73wBalt%Xz)D$|w$uJ3RPngRYBszn@uSi{yC8jheknCMZrgO*BbT zLStY~5P7ihe#tT~74`kfaOTwnViOhC6g9M%$+&cD+ygh}41*Lu(69+DYbc+Z;%vRd zPtD2sy}66#`;=W(6}wqN(Hyz?pOZ5%xDWxz9##UJR}yQg*E%!4BmI(-+y<)Mw5r49 zwp@E9rK40)l6Mb`*@*4Jd$l{2Ek-md_A1u1XT?G=0-mk~)@$ELXyf|TLlBpku^kOO zadf^q|IGQpKniMa_SxNR^DogZ)!Vh3Sq}=&U&X6iSL`@6~4EUo%rYwN4Gg2 z^G0A{DFc2ljz$lY40rt7Y@vlPlIRmwLs6ON=I!J6%~CzZ#UybN@{!D zFZ}slCTB*%#4W|q@JE6?pIp*ToZ&5%Ai(6OW8QQ)=Gx1Nnp6NS;6k6`Bs#SbWs;RA zc+!tP=F1oi6|=RwM2ZxdXwAFj)QE^{8M#l{5Xb$)Wn!O0=h0BP&Xa&mCo8L0gNWp( zdphq0-5tu}%MOllZR#EI>1+azZ66|vzQEL7DL5d1FSSRRM#+g_?4vh+t-f;1Wh_n?+o=Mw>!@4s=c)A2`1p_)ZFi!B@1OmTW z%Y>3)2>Z79zetFl3&8KcYDq-P<7~OC+m%yc6!E3IzfD-ERD8tNNKB)gj5(}KtW_=P z%~QD92EEP;&g!0CIP}Ri$>SX+ z4*i7=zx%6wXEJM@sh!9-twn4|hui@i#8Ghp7%Bxax`~K9qq{ptG44$dZBDguppSRZ zWxyxsLwWlaID#hg2lF+zv*hBEiaR06ow{?H1vEiHaw;D!^SUaAw z*s8OVF6qZBzlCe&H(@eWU+Q`F?tVj*b8>)o_coJ^ii-7Qy!K_ge}YV8!bG;W{LfM@ z>pO-oQ=w^hn?Fp`B&GFuZ@Y8eMQ=Zon&w_GvMlqqX^9V`JlO>%sa!Ho+j_ z{h2jX0y;jRJ+QqAuXw1YuHNEo%w{w}@rG!}8K`2=h#F;+9px7z3NUQvi$&&YSzEQg zOcCnXaUE7wY`FepQ|(EEOZw+!1-6=2MGnmaG|j`g*g7oPuVxa$9gD(#{*%QQPF}~A z?XKKim5SnFcr4kk4%WbWwzG~srXT3~&hC{Y0{o9cE5s}0(VX3yGrMXBDqlSKgSPQ~ zP&L03F{oF)qN6*5e@Aw40mv3uTjtdXJSPyHyqL@6sS1Cm3~noVG;S*4C1mIjm?v z(&S}it>e9az`*g+6_v0=RAzpZ7|;c@nhy69Z-<@fYHhW;rnRQ`fP(#QjVz-jg@8w} zAB$=y5f`;oV`XJ+mCTgw#ZDiLA3L_78y5m8TQn=~%mzA0i03$byqsChc`~`r)dfxp zYN?=9nWOIsUx9W4GsCanJbBB=o8E*%l_@jxpfGuPh$nZ32sHgWcZNMPlPvDxp-lr; z1tb^(qyS)aqu4a8g9oQBDTuKqEG18iBzZ z#1ME@ft5I`Y+zRTHg;@;IqPDtMKrL3Rme3VFOm$vx}!;wZAwo8v511C_`)-tuWzEi zKV1hEAq)ICO*CxK&5NNn>3tF)(|<#_OVip6sY(^H!=$+*;Stm z3v)l_zyzgTDR0Ht#KsUd!hW zkH#H*xVlCf^_Nq{jw~#z`VG!(nCVlKqum>mn{rrLyUQEV*}ZD1+1*uHa=>tFYyy*e z7t4 z&pWtujjsx6!;tmQ%!7s(dg6H-<;i1!+N#~ldNaJS8DFqJIm`F?@WKzZ3n5Nfqpa>; z^y}04WpIQws)(XrU%Rfkl_{5Z%=+iZ9N#zBQ`1ha@>FMs=sZzAX%IbJ9&Om=Ov1fb zC#&+1@LmfPY9Ln+@APIO{D-3j^CK`3v%MXHK!v3b3Kj#pO&2P_E)uo1&G zHg3YbMJb(o)8?MBR+f~4o@>*x8Gl<6^irOgdBS3Z!$rKQL;-Gw*{$yGG9cw>>RyoV zkFl2sZl@%$rSMUhJKFkzBd=MN(u`Ivw(0}h0^+5?aJ-xnF7TEeUv?IzyH2W z|NIW|n%ag!fw2&!3x%7T6Kw{{m7>=Cpb#kIkg+zI^N^3ABffHOn(5FgU2$?xo3tFl z{SMUf)ZUvD?d^IjFI~vwc6%#tb_{fO!wjP^8y*Wn>Iwx4QE4Z3qj6wvQu?@f4DmU~$- zIUKyk?{|^69*em0&A#7n2(pI$DLru}x{7qoFRwgy-})ipBK2kFZ`SW@CX*~Ygw79I zie9D^G((DW>jEZ=TTi+=`qPhh^LObvm-qp2@}L^F!i&2+@~E5c?$|`?#3a@*-Z>t#+qvMN{Zr2-_4wu zXkp@2rCNMO4#U_J?_Za9HL1_1kCvbWHg5L4b>7pP-1&vYrP?0Dc7NeVm!dBTYBisX z1em>8qH#E`yF(B2rkayVg9QDDG#JnUz|>ev* z*C%r_SaXqI9(kqxPzitbpve~t*j3`*>8TL!2;iFN=}D|30J$}73jK_+++|_%Vv=WS zDqBzp**4XgffWg;AWE4BvqO8$O-9;p2 zq9UG(LQYTygkfxmOO08_bTh7``-P~|zP`Z!b77OPG2*GB!Lo^dIT0ltz2B`QbLv^( z?Pxy1?>)zcsVh_H+4=T87a$9>FGB{@un(ybCILRSW5e+m z)}IifEQ$k>(q5u`VVV{KoQc|DV-qJv>G&H&qkfF7^|(J5d_vC0s=x~Pa7LV%oGXm| z@=LE8%UVrGi&|=nGnpI6++uqK%-E;8#4cr?3u11zU70|mX*bsvlt_i zsJUmb^(ZC0Jt{O%7V7^6*yfwV-ON~uW@!u>xDk>K%;=?W_TtFB)?;NAwN^1HO|5dE zzmv36Uz`5i@Rio~b@O_3^t%sPI)=K>CpEmCt8A%mUk^w2b2}i0uqO>{N(>eE^qnlL zSSRx}6G&_Jw*6NpY-`H#HbPKQlF6SERB{Ua;7TW8 zU+#Cb?XIqDrz>RUBjPB5g<6^#8ub=;al);h$7|eR@jz!hS$MSGf98!~usX=SHAp+| zxLv=5RESE-2}A}=J-e)=MH{Nu>Oyfvv7O%=7k4w;lVMQ>cqR?7KJ2b=6Nt74t?#r~ja$b2%tMxgiNJ4!DsJMN)W2fdr*D&-IX0 zAA1?^@IjKsFXR?q+_D< zbwx5%di8k4tW}ggp&KH0Twsq@V?ANag4KFtOElkqQx$K)hfJ{5a#ORM9pH=NSZ}&x zAt+OQ`DUC&`|=WhU)a)Wqi#{=`XwMjcysXMWLSmFloU*pmmk28;0bqIh%gN3lUIV?^u*SogFHfRDPaGCA2KlCM zXvfm^w6wUr<^~wkn61W?=8>$%^}xIssHCFIExoVEBa2?7^U?p23%r8KlzgkU-dQ5l z^T9JT;>73jvz)KwVY@KX;reTju2APd{N4RPUK_Gf7)vpj!Qba6O)|Ode)93nkOJ42 zHm6Ewz`fk%kym*hzd1i|vIIN@Zw;;TCugd27dXHSwPj~=dusTD*;2#7#zLFPNSp}Dt=sY-V{^&N^Ua5~rwwMCZ_9ZT7T*iq`ytJ1XDJg~ zF?%Oo7K%??JO-0Dy3#7KRa3LUA8M(t&uIe}ey_Wca<;=BYBS*BAJnX!U(X-(*M|A< zQ{PmJn(J(-zomk;(%do&F{Jr1J4bMALt#_?+xAg~5PttnO9?t16N44C!1HAxK9dtv zG$cQ`=W&sS+{ih&t7@Ibsct%RgMBCJGHYu33s%n-Kl{gSi1g$@bZ!m3S~u^zro=ayh| z;?N62o1B)>MMY@1Abz;CuSqo#d6d`A>SDn2-PV0Y=si-{VF;1!WEF`035H8CteWaJ z;{d7P9GXI|-o=H%ozm};!=`iMHO1UWVWu^6`B^PMBa&ZZvv z5~mrX_(SP({M8<&OYLrh%Aq@A>xCOu&eqZHN0a}euc>y{rBRBHNdKjWD<~)&O-mVs z9>=-c-bLmFC8hAgydjOwlRjvE#`_Lf862 z{zMLkV+&g3utvf;kWVP4C44FH_|&sB>>aS`jbbFj?PIr(U~8r9@gycm5;#-TS@QS` zlNeP41H$mxrlu?Yi3M2C*M4oS2jxKm9p;6(C z$CZM2{}+D}G6SDZNN{bl_f55V?>c9np!zFX4C+*Qb*E=+e);=)&M;Xsc(r1;C!}q- zY5XARc2pdE0p?B%pLJmRiOA2xA|Ro|I}&vL5gjY+x0dVA2My&9)hNO#N!@}P%V)8I zm(R4%+H=ywww#sfexR0?QF{mCr>%W9o8|FOQ2e|y)YoqUd&L#%a+ z+Dif-+*yNHP2^!y++T5d9I1sFW*qTHq-o8kYTFMhA&rL>GEqRyr!q4!?f=45|3Ka! ze}P+5OLcP`A?kgbzM7es>3Kd1TkiReH4HxW>(L-wH0Uqe9?SxZdU*vH8I9`=Y1J|_ z#b{8po&Ox9rT)#JblFlp`sV7gsB*9_OB0)Wx+QR_Y{$OEkFi4Q0Iu;me%B02qCVd= z=XHUG^$s_qI39XOnKkT3Y7Xy?YU2*!oLpTwODymGG|>oVJ$Gt>a*+%uDU#ghzz8qV zx7z_)Q?T7k9#ruI^@1lWiq!@~b3CJzU-*S+$|$|h<~KK1Ha1i~OvrnuZY{N*G0KPC zS^o6GSg3bpFhx>}M+|1vQ=}y_s0#kr#9l1bj|&e^2F8b9YQAdmd00Dsig>s_=C}Nn zRrxWkdhOQsH>oCxP_5Hxu{~yq|JTCx$;)8K9Lww(zG=U^K?XpC2 zAm&-UOg)Bjz3Do7+r0`CP+|^U>Bdma7&GzzE>9j&zQ7XK+ojv3OYsdoF zSY@N|rX-;k!ay4I+-I`vd3n4j%*FJ0-BAl<@Vq){?*(6u1>Fw|KhVw$M^-9;2i~mb z4AZ06#Ju_Jz49L&&mwSMP7x$_A@t!*rkG7DG3dW;iwy|MZ#w+d_sXj^e|nvW zo{l05IP2G(E&X|KN2@WkzvqmadVGC-3FldI**z%aK#zikLLSaraiS5TAGvrq_E+nd zf3fZ~*c^`@Gs%fnuRl?g3Ls;gZxRI26n_06HXA$;(X*TPbJmnn2w0CK z#lE{WWO|sj=CI7qlvPwYRRUQrgjNNzFv}`!9hm~Ari#~1Ac<4gdcez^V0H;Eu9ma5-qx>g9O2D*_PKMIqKD(l8qm}ZEFET5 znGa52OxelqNBK_*0wLJQb=FpgXe+qPnF^3&#B9ONICXrJHXlJ=-nc|eow>UT_p!{@=~}OaN?cu-u=1M`-RD+5MqeNtM}kzS3;leLEaTHhe9$ z{FSq!Dz$_K)rpSoko;n@HVWgneuRbRJiOOtY9sMKZV^ZBE&}4Gonm)4cz$Uq9sPw? zxJtGY71}CXci0Xr0?pmel2XjPh?kt1?e*l3Y(Ut{-ks`(Lx+b>EtL1YJX<`Lc}IJ0 z-4T7+bdt&)EADevW7O8F?G)&TUKGqO>)tMJ_!Om7PQmAIt1N1C`EUNROW9mVY~&Xf zuS2y=N*Umnc0MCWQvV8AWT{RCkRNKv%@E7D$g<}*_ODqUY2xRF{w#w5R=$ByKaQgL z;P0W7xln7e44>{yxZlYP_$a74<#|3b;a%977d;XZY}*SH#QCai{_X zgC9+I*R<@7PAn1F91#H?=vcy>vSIy|n^Td!EuYx&*HeSdl?7oaP7SIGlf=QIeD=;! zt4c`7oDuTYt#C@c%75~;)V;iI4oK-iUigErw}K(cQ8X{8=fkD82Ea1QSEi;5JPS)h z0O#3HU5jaV?;xcd8ZDBqM~Vad7?cRaX_l!%rxYZNUonOevx(Cq%*@Q-Y;s;0=<5;E7k>eKA`FJJyO~wHigM!m8%hqw zOO|QVjRR}eNdUcrX7kgk-e!!B#H9nGPCQBZ8Qb*^t#)dDaqWk8+QE9jc?Nzx1UwB7 z;H|lgcTxTt$xl(J8f&HKX^t+`d3|X}F71Wz9=K||xOgZ9u$kCaVfli=u?X$JK>ema zupcBrDPke33JXO|3+6?}!!bq-WcL#D>KvHG7pAURLzNpJXRzGeA zm%(1Dia@`%M)GBhR+g!uhAR8hCM0dw%P)x5qmCzU z_m83p;gA`=m2VPyJ3nldJ-wMA@|Ud!N$VKAuK(%eiFel&l(tqCFP{k|gF2hq?`t5i zhoyBBta2v=dEi8G=##pJD-daa8r9EWx1)@z4kXm2VOs)Pq5DW zbii`42^N0fAW}SRwiT~2G5&6es78HLqTez>H;>@a&`86@*@CeP?-XpB#l>AzE47#MFSyqKGdEU$aT6vH}rU{>kQ z!E2o!#KaIqF7~fCviLLbp%G#_+P?aJ`!#!IrLKKts(qzySe}1Nb#KZs`cw*q=vSxj zD%sC~nM#lEbQ4OV5!yPu?I4C4BM;gt6#wx?hp1=XC)Q*S-^nhf78+Z2Vb>fuGI_P4KlI;Gu8d6cD_at-S#7tLL zE=tm|qP%t;Tztqg_g7{v|1465l{S{5agm{O#>QkCY~3gXGRf1Vt`44`@a%wEHng~i zE%fsr2aYZcF5~;lzD*HX^cJ@6xky*hW@oG*(-SF(kf`by!WrV~t?$$Ry^FP># zx#?IDF-V~m#`;RF@xvdCC~oA#!>41yB!7UwJ#A`G?6blCneexU>psV@I>!7I*k&B51I3>5_Nt*3&* zw)wb|d;DG6TH%bdTfNbIxv)`m<(JmOwMAEIwX4!jul@USS>;0n1>c@4yS1EYYn{{4 z*LsY~!nYwEQAgGyOMYARKI?Dhc_%HAkS<2(enPb|%!g2PK)V{z(Lh6C{~5nTb@HYE z(=FxEz%)gKv1^O-BJX~JlAv2HFAMGgnvBLd52P}uh~)obDB7auy^=H;MHnBYOHCEe z*l%%(J*ezZ+)>Ba>Ue0mKHU1oR#H&l`?Gzc=sPIf@Q{Ii<#u}2g!dGo@MLB5@O|79 z(4@45zIoRW=9rP$#^a`MHF{xlsiJB%{K@j#VL?mDinQ%Bgy1Z+p6+11Zk1+xUMl%_#rKRMpO90y) z88pnE4)j$$lrTg4bPI+96ztL*HA&(A1brDyo@eJJq3Q-&kM#x_h; zcDBTe<{Z~Z>BGn281H14pNE{j=pp_11p5xR4xiJStMX9eFEG4Q(g$$vVH?=$+01mp zaDCs{UI;ab1gW2oz}37zRdcm1?($H&bsy@Zs+Jorw>L@FwA9qJPMx>V*3`)A2(IqP z|M)(*x-IfkLbW>KL?ebK7)%GCLqmUwQU2qo0Pgaq?xFPbdKZ`8j&{CMj+tq}%kiT8 z<(J5b)bTrQ^{=jj8+RSPF5}-AXg9Z*yZpm|xitJ|3Mhfz=Yf)k(vN-gZpy36zoB{u%AGin?}S=2957))o%w-o ztCO7V3GH`8H^^#zfTfw&4$BxsG+F(=@XskwtE$g*ryir69`)+j*sjL+LyR%kMe4BT z>=GvS8YMoO<0Ob^5sQ!=+RtpUal0u< zA4*0>2_m?x!Nf-o)&FaJA0H2P0BCO#5gbubc)t3J3!!88@7i-oC0L{zgDSM+tb@kq z4Zs(uz^awJcGGn+d617=PHMwctpA&DrCyt-#M9P)W(V^m#i4Wurs zG!xvaW=$XtEtLx^ECyZ#Qi8m9PB7R1Y`EG`1jiKo!jC|`s9yOtdAcE(#Pg9zE+gK@ zfG}lue>djx8rY^Wy!B>nXUtWx_hMvt7Xvy3om(D*STp1mEZM!8va^-+7kOR3-})i) z3-s8ONQlGqq_0XDC@DLfIuCm3#q<{8RryLhx==H&jbEpl($L)3?|38?Bw&TxoFy9D z&%NQ`l}R;s`P%BYSRc_RSrjEwj79bTYXPJyXJZgYtUKIpPb-q6LN~2m!CJN($7|@- zvoSR9#5NR`Q7gGRtN!^TZTaX;tFb-MRAE8C9_R8A4?<%9aqN0Vc1wtToXx=2b z4_6^06pk76?|<-C(=fcpKmYR6mWfljHLivjwjg2RFkR{$2rFRsqQxk!=Gf`&=Z9k% z;v-@L@x>2SNpOi35LYT9k*m3{2&0d}iJxa1z9=(SBFFAi#_m|)9Nj-z6~X|ubqVrw z;e8jIBv-o|>y5OG?c0dmZ^L5EQ_a*kT0C1OCfRv5WGJ-MMNt|b0dfNj)Fzv1^%(rf+l!=9Nq4;6gMEvjhuGLJE`{xMPCZ2d4^Fgplc|=4ij!i+ z2$ja@;x2)jHVE#&o^9VXg~M#hDGpkkYmhSeZ-ULrvf7U6~2`N@ZlxrJQo zoY3jcU^%nyVekvsnk@ND8!EcHC1y&6#1a>2P-~J(UanZH_oV1$vBxtVTI5uTB4Qko z28j6~Ca+5Vo_O;FpO=>h8a%IYo*nTnHJBoKiTYzDPNE4zn0E{&=caJ_44nDShhEEk z+)PP1(*lvheYp?bI&LU=5h34!aV?1mrdA>R;){@@wH*8&X2AKU9I<4$R*aezk2znpCx zjY7H4`2PMrg?OEQ07d1y7`kCqV@Ew(+u|=@M6>{?>@GoYW0i}6s2@P~{LZ1yXfdT5 zV^WGkN#o8JutBZ>V8^16q=XSqpGtTG1bNcJvG)sqYb)R3s<;;KGJg(Y)z>eY8{KvR1yqClMiyf>bSs`jXSkSP_hV47lURhET z!5u3rWb$%Es<-wdinl|4JK*4LQ#&+OThMl@bQ|lqkl*cDSJxU^0~#R8_3U)}F>`l4 zD6}R&0plGU!$?Pn@7o6xY*G43EXgrQ_8M-C61R z8*5efbY4M=fe~RaFabvm@c$LWST$wiUOB-(oS)n6l||5?O}c}Ud(yL^E`iPm>9W^F zEwEg2pw(Bv6LlMP97nr}1}jolkDY$OPt{oC!CdHK2cDJD3$w$+p~`QW;EX3>)6R3T zeQ>2n-73UOdWZY$&WVSUeMW7`tEU!M>;De&LV*WtY`jdvt`?063WkbY)p&d*&vws> zK2kg;MF8scYmM;nC`3MFygw}udbG(qC)%Z%mD&kaCwiOY6h!JMnFj7Jbj6AbUQ~z_ zT+?Iz`4Pnl8_wcS?_wRVUG>q^y12ibiq@czdUByF-IQhV=kk02gKgn=>0+t>o6Zkp z;hBngnIS4}L3{hZbc`G+qN*49)7cb1ccVbVp=*i(uy(et*b&VBV4YYC0Ovg9XiLaQ zz6MHdq-k9ez14L%vY}n^j*_Qpht?MZ|AYaU>pyE=!TmEY`1kV%6$mduphFeK&8P1s zk@p`PYT1Om%|JW%vfuPlY%?(Q*{ss`+*JXJ6d48-V#4=F?J5XZ*nKlM5e=T*TOMh; z=X#WhN&G&}o$J%WihJ(cnpa9PNj#iW*aa|3WO=$EXFR%8QLDeb`yWRU&jYnhliYA6 zVKR*XFjlz(;@zcp_x9+VJ@+c1^nnAgkRao$*ZlAcN4j<0kN3`RUY@k#V9`?i8_|y< zfSDOO8clXm-eE`wWp~WikAzje zYl&-YF;2QsX1;XShWFq!#1tq)^8>bXos&;)zUlBE{l@gqtN1_9WQb6LepddEuHOf6 zVTtbl93zlCsDjxj=B7+iv=K}vr=>?XY=fJm#b*$6H3z}K79amVy&BT-^uH_eKeZN^ z28M`85#N`Qp&TDWa~2Ran7eE+^F0lm-Ec5NAW#9Dl7QguMxzMALybImrW$|Xy8TXu zn{sYqkQN&(iuv?81R@Sk77;97Yp(uJ|NHOfJ+xZ4w`**FtY$Ayt0?vSynM2ao_s#( zQ{T?EMcSrC8V-(jEeZ!OZ!Ms0@rB#=$dq_tpje^VDm;8){q1itC>YIX2u03o{E#5y z2JZQ^P)ZaP=HDkBI2>hEoMue$veB;s^W>wcF8|xI=pc}=u%A7cn0&M}2-aF9mVkqb zS+o?$Sz=^mt`cxN-;1q40H7m@6W~eTAs?{Hw~@!w+8K$5!a;+{mI}9%z-mfq9|A)2 zLJ2gUK{4R2&`rrqzRGWCZEBZ(`zv&=1cM*h=C*INE_{ejo{L_|p|XMqiw{`>9Yz=C z)D0^a=D*KlmS$tyvG_CWzYR^QN+0J!TW6j~80+FD_f1{Bd{%j`Ard)n;_f1Vkba%( zj!1A)Rl#UQmQbP``wV>lz-HZ;0r^@3$D)d=nku6q+9y|F;2BoJYvVpE9zI>fTZ-i3pV9q)PiO}HtM|{lQ1%Fb1B>2k&@C4qo`j(;g~-1}2lUS97@?$& zv!+)(!$Hl`lt_%!!88_pO67x=3Uv%Iv&bOOi?&Vw`4{>B{^Nju2}92tfr{?>+Ti_M zTomeWi!W9#OJ0VJF(a$*CiVvYGiLB`)V|+*?ke&}tx_ojljx4%?wRkA)LK$nAZQ!} z9R+CHuX!TkG}v4)u4wVJe>!@!4x_Mtb&3Dy8mOv}G1gl=A0nwGWYm$lo;_XwFw}!U zd^76_#c1Z}JwS+16W9r4u^+qfH9q=_SB)?ueRcYy+yp?#9kl0fpsX33zE%!zje})M zVL}>VJ+Zf^$}>t&heLGX<|9P7REO7y;z5hY1%l$H6v4&+p5FgHeC7@w9?rb5{&jO0 zv*KoxtS44iqeC9?4^TdC_idA5oi{Xn=0prRq-hp}KEfy#fV^)yf;C zGP)X^qF_VxX&Mqh{o_M(Tuz=y{CkKMhOA!-sVn>OcAtQ#n3aBhJGo6#5_s@q>*MI2 zt-Uw7#@n@IYIYx7KLPdOU>bZ$Idq%6c=BMLaB=+~Fd#%)AfNqa0`+G(@PB+%9YA+J zmJx3xNmp>CWVW^ZsL$&}1Q-r};_(38vWqw*am(wPYGy(pghUE?jbjhE# z?CdBH%+H)I22n+Pmzh>^e6~)GWA%p<2N%du!eim4uD1}VD1#6Mut;;N2LasgT#3WC z5dbWGEj?le!w|<%X^3l5J2`cR7h=Zo4h^Oe=JS^ z_q$t?#iz%2S<8Gc`>le`Vsbezb_3m+9uQp93_(3d#XEc+svLJb)-Ir<@)EBRVcyF) z3yfF~>Ik};Qdm<1;#{*^5ZXZq$mzB0pUHuy&TQzq(&sisG$IAqa)xy?>^R6nd`U_c;;_Zxm7-{Yg>b*tp_u;TRDmB= z`GHO0#qkvzKm%D{WM@gsk^7u89mE^JPRmD`qe4aD_j-Ev;9m&VaPH%(s`+OjJ|U(Q zS7K{xcIcQ%cdotdBg(5QkMF8~ay?g=T@VyMf+NhpR0^n7carL%F$*uaRBrg?pi*+3 zcR(QPk-|GVcZwtqY0U80fxE0_FQ^~s@nub?Z@qsLVIo;*4na=~1|<1D5e=AT$Qi?o z{$E}h3IqVp0BPRkdQLzS!vJ@WchLVSfaG z5PiPu3Mk2IuB7XNbnXjhG7Qz`2^ak1+>w)V7-Qcw5vNDX`joZNG|VyIe$EPT-Xfm={f@q2ZRpR5*p zL}lZULLH6m#})5d(lI+*4L%)BRd8%4>K*c&ki+*r@o`8uVcBXW%5vtxLnh&E=D{=p zy^ffsXZ0{CufyE#4V;wHY?N7x0`;#04#-L_c!1meK zHPqA7DlFj_;3jD*P@}e63{J#&c~+o1nQC>nZ+d5GvedchfT1EDiK1PJ&vx-N9Y@Mh z!x0bF@z7LMH2ll9pAJox0fhPB;OqQgX(`qCK^u@|zw`34G@?8`*|syYqWcAIJh_fE zSfze{c`hxRr~p}ki#4W13msXMy`j`GWJL>o?Mh7n_z#Q@H0r-?pP6pD>@|z}s&Y~W?)C^x!WwTae$B%#8-;6hSz7KD*YbdqyA1~@(D zM=Z?D+BJm#!iS0)Y3sJRoW{rvsB1*=SyMp|#sEleS41^9p#ScCvs5q5?BxQ;AMHs8 zhYzZ3Z0%G^uGO_(vLeqD*A)G@k}G{*R(-ur>@cu8Qkkr$P5@1OR7HFg8)m6shGH8jUfEpKM0jp6~C zDRrc79AbJo@W80H>B@;@vR;Hd_^lu=Rmta|as$)rIBzeu+AmF6w95wO7I`P?u-#cP| zZ5a`M^eXJ9*NXt+kcI{#zI1Hv?!lUT_^wdLyAT?0(iOr!e%1`JegLl4M=y#0=hDIw z6I>{RH^3^qDt^?)SU&IsT}{;152Y>XYRVy1kyAHX6Ry0u3q(KmHy=gVb8dlmM9U@q z&&>x?8{(8$WF%DVN5HGSioB&M5YGn|33>Sb9*P`Tph?ZYZ!~t{QScQY@hOVhy15LyJ5he&ZEc)2IGGS zM!o`W`ddqf&6UyY3p4@PFOh+Xav8`L9iLBM+xk7n<|$dIVPJo4ZVI$6U;mH*=ux!R z@hCc6YN+M`nnvuYoId)3zI({|GZmXlK7j58parx6yl=dmudlj7{b(7HC0aaT@XgM3 zhbIe-NM^=LmL(V$IynhWCG4T@WMW{WsfzrmTGj=FrT!<^`PcpX^PHCevvj}cCV>2* zXFaQT{)t0UnytJ7Fk+-(TSZ3%wUXVbDtu~&-NlixM4XKZIhb#~Q}R(QGQx~Z5ZDx2cI)>BPx{Zc|bhj%a27sl4%j63@a*%;Uh`R zVizrkaMhV4tUGFs>*kb;H>(qU7I{kJ=g>qzVGYj{5C?B^PSA+?m|C3*AU@;}?y`A+ zxqE0Y1QUL^Tp_Qe;!2S8rl_>M?mlo3>6|G3h3sApaUC1jSsc3RD>hLx!kV5=;J&xY z0I=yd$XJ1J)qwpQNlU#;>^0K+3t=La;*?7N%$LfqXM7&n4Z@07o!52W`asGwbQL&2 zMyPoZ(DQdM|4$lV0D2$MxO6+6v$Dc5j{SUQzZd1i=US(KeCy5ouvR}@_Wc50^nFY zPj40ufeL?1@ zm~(-h;P?3-JAzq+9hff#_3?JQRmhl+Cks{oTp%n_n*VmJ8)R%p9jL%2$g$ClC@lAD zO@9}cEr4E>C=*;*BP+GU)JF7}Xb7u9At|pp*;z;_MY_*Bsq~sqlmZH;OanEpw|h>x zS`U15m@$2pNi`)ui2$P+`>I7B+P8I@^W@dN>-WTqjr%)11|)sNRUx#Qq9ND& z46rT2?hgxi-L~*QS($_}SL)xe9N@4Sz}=&x8wZvhFE@Mq;Z@0_Z^uX780S%IznC6B za`@I&^d*1Za2|bLRhd$UJ&yh&nB)h^hS4Z#wx91wV3rhPfElz-${oPAikewk$dr|8 z`5^CIN|`jshEP-WflshC7?Oxq`-PWKkRL+QYVGX4^K`OJL_cc%ysC+zVG0LCNDuq^n|^n4H;*T&bQ2>>|)t{Wiw?m~85X1jFd(zD9PK$>2@NmCA z+C~&m87WZg3wpWRn0)^1C3?vB>|nA6FNGdQrzZ9tNC9E{_O>m4Z`j<=2|5fYowD=F zd_YiUQ)ki9x%i3%H0wRK2==p1-2G9K&VS{j!X!h$qra0YtPiqbZX}E~wX}Kd5;~zg zkqE;=QOWYgVNRLBIqNKQaQ|7Hmdk<-4DA1CtbN)&8GGY>G%>=weEK^_Sdd~ZbH;P@ zD^IGn^d`wy|FgbNL6j6E)~DAyDGg^|a)hd$HcYE7FRA2_ugN+6^Hg?g@173 zRVKe9_q}$_5b6j>a~$K_q8mwmWduXMuA+vnL!cEVh`g(haDsxJ;R*qYb?)G$r6t;* zHM(gC-<-ONn25hw&&*Drooo-gVz?h}mzS<@R-S*msxYy))ALYNv-Ffw(w5?Wl0$0X zp8Uc&SuENyc9|3VO7&^2CA=CQ6&fYrOqb_B-Va^Y+eHgm1SG}h3IYtaudZBdxA8BtH7G<~g55q9P07^(o z4&9A(4ALPXsibsBNlAlu>ezVe$h6A+ zn6z)aBO_LNJ#6*S8ZHkCSgK^T^^KYHy*>qO7Xwc#Ebhu9{B8<#BAo zNP8Go@NtbO`76yEu*OYHNzyE&e2hjONQeF+uS{!ceJ-tdm%4zFLV&T4TSEC>$SZEOdG@B_0S&hW*;hYV8CO{Q3$uZrgi!SbIcaWnIaJ z%$MT%kf~!pl80=C@Vsbf*j=Z%-JFVE$<By9`fGl7KfYjGXn) zVReS?^YX@QHAz}p;y-E=VE=H5l=MjP;HD7=CIZ1*`taBY$=yN#sFeVX`=|#_aMD%s zf#EQ&`wYFrAHK@9m_63i6JliH=27m}j|qu4%{-DNM5Wf7<}qxQ7SBWbdW2g3n4PTS zzux}?gl@nra4tjxF%S1;wpAR@?TOOXQk;mPfa2`3vgM%w!UKN(gmCgHvnB1~W!7k> z(;WMKjrvM01Hw10P@w{+{_vnfpA()i#^*)%=hp7%Q@l=mYF z3aqHM=?C|LHb~3aU-5^^iZJB)&zadL@gRbzYuM++wb9C_6r$ZRz#xRfIyqLx>&`h$ zHY7&xtB>+7;EE9LRZPRJjce_4`w!Rkm7TPoHnivmx@}yfU1G$!{|g+xCitEw$v#m= zyu5esW$B>u!@ER90^n8R&!yCy=1LtaD{t}KgFvLt+moJvvA9I7qvS06Bt{<)fNvbQ z1-yWACF$$PxA}p$Xf8)(-6oMAo^W{|d|sd`+W$g8@TRlx5f2~VdXF?cwg#{^O?! zg}aq8Q7@XT6bNmWnFF!GuW!1K`6(*?nS;*d2K>zE>#Z-((k|e#6D*%{bAh=uw0itC zKJZZ=^LgUZD3{2LWA_1^o4yJvV<$66(^!uRV-7UQ&4Jz2${!UQ6k)XYYD&$ z*=wfGs@wtm`CV#GVDhV}Sx}vWG$zsu5@7C)E=0bbZS=vXPoJ19+Pno1`?W!|W%{LN z2oel#hlv5P{i$vp1IBQ}7YE;_-nur&Z3p>W&b+#|B^uEads(>Nym;+=9ea8gN&b=Ws>@RoanlS;By@(6 zN%grY#ko$!xv2&=EP1DArs~h6oK$~?yki;~UX4u+xXL0|deQR{DNsDa4or4(uccqM zNgMj#bBu3J4J3-AQ|E^#^hD!F1(w_wN~ueMl1+Yr^=o!UUcvLu)yUNGnfn|AUI#V2 zKtEc+T*E!lJB8^u99%;l*VNY!m?em2EOq84CN9daou7TB%^T7{(YHfnya0m!9RgVp zfw9Ibgc%}>@ri&GWkM`qBKDoe-K3wwLEh1inZL9bWD62yeqIv8^5b!OpwU^hM72l2 zBgiz+Vc(egh#9x2a0Iwl{>e`{gn31Bi6;T%N0+{qT^sj6kWBE#gSGeS#fHF3v>a^3x76IliVD8XU^jbJmolVjAyNIS!nbxJMhU?p&)6|FTryYgd%x z$T~3Cu029Mj+bKbb?boZqp1J6SCU?>9g}PbhXmHcdz`*$OjL-fV%t>t5`2GaoF6dr zpJGQ<;}q0s&K2L4sdeum{ehLvbB1&VUOYXa;G!a%(pYEK{+x4{@fnTrqJr(o`XKbj z_)=gN-*jK6hwO5)@OYEuWD_&n?P#IK>G;##z4>WJ<;&U7^V8|;Bu8u*0wVb4V-!6o zR&_Cajn&)A`suPy)g#guC0>cK#S_6ZEx?PQWiFPMiTef3gR~u0xcIdyan-L|Uc3lX z7Unxm=rQNx6M`48=^q$4ZGW@W3a?$r=Ulsw(!4zMec4ulkVge^8~9!ls@S*TZO-oDV=)AYS;`8=6!jwUJ(RA>kBXa&jU@a;n~FF*_ot+YoUnoL$qk%5xN$1q)HK-4u1p?1nPV9 zfh~ZjNhH}PIFO?K3_F+f&=X&~y6R#$f|pfaef>zPd`eR~f;Gwgs-(*tQ(gfseDF>T z5yJAMVPDzR`(s679fV2O?eJMnvg%Oz_j9E=R4ksLyFs&_TZE-9pSNTsqgMxWloDTd zBUHO~NkXr{>()BmSR-#!!;iwhEPVQ+KOx{|*`!#WSGpCLHN|SS&!{GS`gnGZs|+7y zZ!tWh_RD-31#{DiPE>7?)wTLBkO_7b?p?g#G12Lg5sntp<$<-Q{1P z2t4@^L=ega@n46#IU!9E0wU6(mjM8F)7>{y=TAwRF%ohq7~9*l(YpU3QRa#-*kv#HPl?&bc*NXqY-` zn5vnY?$;S~x4#`r84n7Qz#;cu%2cC|{hCM{2Gd2qD0^{i4!`Dsx29rk7dR&vy4X(M zCkz!iE~Ab}=GT5+zU;(j6uS*VG2$4T7k8?n5wvXo9Clb`DyM{q#92l`hlh2?0uFUR z+1u^GWcnQbb;O5T^xk!=cnfR|Clda|*VoUSVw>t07jHh=%6}bH@7F;cc)t|t3UH(=Vk0 zKA<0w-`we~KW|zj^mINoxN_K2T@1ECwRfkPs4PF4pw~+ATK8C}nK3nh-y?bD!P3`j zi|Ek3O9D@3zuN9Y*Rwi-44$}2VmzdmS75@|uCpIr%b+-wzv2In}X={>N0Fo;SkW`)=-1@h||7(LPA-%2z!Z*@kD5desSTEalDeX!nxPDSII&T>w2o5 zlg#Rmux557jIug*Jrxu`4m4~I2rf(Sf7tv>vfCP9bKXz*<{SNF+;KcXy`g~E&S62oyy)?;}cWrxfuoMEP4FOKG6`63W7+z9)?A)=B$o%K{X30wv9& zF?84%#=SiXjVo8X!`TsT@WR8Mm~Fw5=Fi2d$1HvPKQg_Ci-=~beX0!b8AD;4=-)04 zd&5~VS1OKI;|07r(EMcx?viN;8`}Hv%GJA?k5=79t-f%#A0sJfdjYmA8x16$bN4=- z@e$JLmis38EV!t9$(y2f(&hM6^r2fWo8Vz7PB0}lK7bRd&vMlMmcqY4-tRju1B*PN z$BeGy3=VVi9G}P7a+ZQi0^Tf7`|R`x=YptL`PIwo-Gv+{ox?IamtyTYkMk<4ETr7> zvd8i$E8(dF$FI>YQLwtyI`052Z&A1};XmO^$zfv7t7>)zsw&Zp{hM0v*u9nFxz@WA zl|Ah{WGI?zDn8(S@A4I@8%CR!7mq5U`lL`qGsO&19huyV7MCFr|Qk^L+*k;B`b|It+fE|D#M3Vg} zDLDVF?+Xv-OQ?g%&o<;RVs*XY{`5iP6Aqm8bbi3A;R*Olay*_5r~QyR4Y=QQhwn+T z(E5qrV>vhK$7Wz$#O6q>38Fx=Rn)BdIiIYLGkZrV9WCzn@THuqouypU)vBw%O2TMC z;5+JjP$HDSP4Hg|1AC5pe^Rg|GB8g&Z*zf=_&KRJ|(MLlZUZDiqJv|sbBvDTl zD%vPe_><0{saMywMYB^wl>NK3tocE~^0ej*t~u%fo?t2#SPS&ayi?%6jr`v#2}lY| z1tj8bCUE~)?2lMoG1b@9gRS(nZIaYa=rOE?XsjpUtEPWvjE%}DEXbvmW1sSh|K90U*n>i|)DFhrw_oIQ7(&>{N#Y9zlxfTr4InRZ zpoa6vK~ukp_g{bJ%BRhxdFqvIRi?BXxy^UPYvA8ak@-G7LKpmCtXjSuC4%AI(Ay-U z_ogqOJ@+pAQh#y1nV(v6F<-4QV{2O|iKwcG=i;Hoa|JIwO{czbPGA~0d^zMNy7S+B z`4K2E4L*jzJy-X<9xVX?< z;Y(g;d)_sHuHFul3W1CrcjOZ%`*ogppP%Ksnf3}LOlBRTP*Ji;W!KQ62N~IRGNvP^ zI}8R))tgYI@gg3t_!ihihob(srTmv83a0VvDH8J@q~FNwDZ8wURd)aJ{jGms=(>PO z<_RNLCXL;U3Q&PYTCl@W0I*#yK+0(Hu(OLjBY}Vu$@SywpwKnItNL^qB))Xt>1W}# zvH*eW+{6@vx8$=7B_S3)_IrsWk66iOk;v00T#p|Fry|fD9OKj?4Zrju@g>y&5zZ5` z*$Y|yW|RIB3T%8ov>Ys<(@!`G%XZQ3e2KtIk3gr4$V03-Z9%Q>dM~z0mExmnMuJ;> zhe(cA+wL@dvM5#3wbU+Rp_1KE&`Y##dK?aU==tP_DCS3XsqSJDqnIHY?4VEtoqJLp z@%sPv#{Y@ika%Cs0R30;{annHe}gZ2 z*oW_hx_qv!FVdb~jvy|wZ%zY|l=t-&&&*u=omqa8_%(+e$oUCp`Of2cR>F5s{Cttm zJ}mX?dT)eWU#w@zG=D0t+$vhEdH`t3&_;Ou3fw#=`~EZm6c^w-HXx5D5B?jQ{&jk& zCbCmqX5zNkVO>2Pn_i9OY@l@;PUlb5^?WAcwkVX^(>0Oca+Eu4{QIv;a~_esPl>-* z@ZMw^Komy6>_xrHrudx%zgGT2Z73@zYO`*!Yi!e$F~b5varb8vZX9F+yvsraME@GE z3dK9Oh$NH~*{Mse4gKE!Ah$L66`m}5ndsnj47s87?@wo<9fgeFYRwADo+Xo=Y0z96J`uE+B(C!?%Y6aNC2*R@S2Gw<@o@z}! z)tG3owV6;Zz``QCnd(N5_WsAwv4`NO|e?_bA&iGbGbzZt-!qi$8vj<&Vgui%!K zOmAx10wSlz%d<&fC~RJFar1QT?s)L^WsjT;#gtAr4PZhbvLF$D!lz|WKUge+InhqY zKd|{$hEQUoA<+Hw)d``HF>c@{w#GCy+_Q~di>gdP- z(}!(@u3qGv$X6bi_eP+)!1K4p=c;H!qLo2@ zHH0-gt}?}XmFIqbilseP@$rKS@k_Q6mWz2Zsxp z2JsC4GaRMTHh3P0$!38zJtzr>C(IP0>wX#D6l*S~;g#$s~m#NwE;a01ZyQ z*b-Nj%Jg54>xM{b0GOwKx<>j38hl~gnF^8Ne;{VpH+JMwu0|INp?TF3)p<8kS&fB1 z(mVu|0F zih%e4jnqNCf5{}UnnL44@5We6fn{hYvL6I3NCKmx?D+89RZAv!aog6&FzWMk*>cz1 z2UdZRArY%jrN??DI`i&tXVEa!1@{2?#3hT<~IIy?co4-QI zA!Wmn1^eX(ONTrsyc#)(w)i%g9)--B&WX@2+Wtl^!nUB%toP$`IQC{T4RP&NZ1?q) zt}&*#aj@|+3W$<$eqb^3{SfpI`N)+u{5o)9Fc#f~s;(*B0^1fBR0RtPemfQu)kxTC z;|^3<3!-boaMIXTaOy7}Erz4*`jDo~s2U8KdAlg;O9rgn@q+?xEZ9#*;}7*!X@n_- z4_b!)G!0V{(4~rmfQ%HQD<_bmLJiQsT+(swDI)^eH%8R_UiG-s%g ze!DCyL50TM=05ECgYQtSC@Vv1Ai1_$>F@#__?*fjS6`&*g^l&rm%Zi42Qm_n*J{4_ zC_hHBZ2s6*P*uwkH)#_gawESBZp@ZPPy~pj! z>AeZ39gQu|YL5dg!}(@>)`6fA#!Kendx4z*nPqN`o_P3!c{ zXz52Q6ZQ7bv+2eYzS-J!VT@NNrm_{D@9&Vh(OH7k8DJ_30ayW)lz##@U|x$sPs0+6 z_hrln#f<}ho{~AId_2#h6XT*XAC+sj4N2Cw(Dix2?(k{1hID17KdC$9YnvYdJPIx6 zsV)`vGr%_EpyUYsL9!dDzo*^e#_G54Ok=4(p^-dg| z>TWqcy@6v^ByI}QghEnA`=YbX&rg)Y`V=%nSuB=61Z*s!t$ef( zgQbCze&r7TNxs^};$R)1~AhrN3;KaLUDRs#}*8zi$FDy`!$7W#9V3ml!j5sH|Hva6aG@4riMXifI?uh3qU3$NuQdUHMKZyXeTn zmKLl>!P2^b)ud8V3jD|8|K$LC`;9Um&$;cVWAvY@d}4(@#rC#y532Cx)PSG8PfWc! zTDUKA?&QOMRn;?hwt4xWh*o52^TiX9d$cNMev$j{BpO0MUo>sdVn_4{0#0MM68;+4M z_Z*MQOKe;4%{lgZ;G=GsO}pkt-_Md9-+L9=e&^x9z>#!&fQ;5rFGPCnQ~HRH?S?n{ zUUv6Wp{3YN9F;^MulV0dB@ZUZJNAm=~$HKIcqOGv#6q^OSX0v3rD?$*4()O3&adFA4%K0A9>! zw9XSD{Q;EFc@4t|{@nSkLr1Zhp`r8>uV;Bt=_zZE6Tlz`>JpdHZ5=i*N7uZCFA039 zIxe}NuDYt(_%e4y2adiof0&y&=0(Yh&K@SnTAgO2;~&W;$_h%Hlcu{j3=+Rw?>zW+ zU-*wlp0JfTD~#T4K3CNDZp$U_zg}T+Q(77pD-_-7OWcTIpRwU(PS%?g$;F|@$kr2B z9vN1sE0?9jJ_iwm{QN*4d$m7XyG6%tphp;jm5=qH;bINWT^1UZT0I%V8+2_p!tjaX ztGrUTET&6CW7swlijA3qV<4>t;q&t-pH~NQP(aM@Es1kK`W+2k-5aoaaCm65N7s$X z`<0zKo&Xh}MPL1_k7R?_MtG^xfO8L*?x6l6zf>lS`RHjJ;Je4boJ|*Jv>MMx8}2H# zj*&R|oOKF}Pm4b5x(MTD^DG=~I$5r#onlMb+1$XeWnk%hk^jyjcA?!X@L^JE3-&AH zR4j%h4MW+Xd#th*236k^*b)7|yX%J%{3e5btB_UQD$(L%#~=zxGi@phN=&Acc`^qT z@r-eDwa4#(>PU&U28W?x`1lt*7<3t|$;#a5JgI|vJjNf^aGmzbe6IP$H@mao3$;G- z(~d7BK0bXu#2Y$=?YviI*RK{+y&VU`fRc!Pew<3Ibh0K3wS|Q%E>Er{mdkirHfE0> z!7+yD!+T+}x%BNO)c+$50#ig^TFw#-tDn8P8e|kxjP*DkX*?b~>ldAwp5DAnd)3e| zg27J4=>5VmqvTLN4s1bySli<0b9s>$sD%H@8vy}^ASWrVPU!5!-#jV0GDJ=8wq|si zEzz08vpU0WezE-xn1>Ix#_({})Z@5XYbV+6-H4*J<0iJ;Z4$Lg+z&EFA)cJMIL@r= zWm$lHelA~;^*QtPL~EK_oz#sIKn>I08m(o&%flhqBf9+#1Lih4 zl*dYO_pH4IPqs-mnoiwg*-`QDkVXsHoUN8;-{H*M(PUG^dTpjbxl@cv$R9@ipmzt* zM1z|s{B+^PW{o$Tz0vK=@|sOd#k^6+E4MT|Uk({4KP2V0-;rzqMpGpE`VmFGTomI{ zgEOre_gQ=3xjx@8T@rgF%Ji5;@CJ$(m5P4{!M3E9+(D5gV4bV$rix{Qenre-$u_MV zLiS0@J|2Q+Xa;peu8QmqDxY`h5AXvCc2NB!aft-$PHM4=NV&>#hS~zd*99C;3$>EY z;37gyAG%+QH1D^rnPH?Ad1vg@Zsn0`>Z;6=+P4_WsGSF%;~gs|sApfz>_c&O2n}3(sWrB4Mzfpjds^ByAl-#lyX@6K~p6?$|l5%L>A3TYjRj`%icX z3iqxgRJF|q#2)RAu6Cj(*s^yGol>*|`7W`B+K!nn2=Cso2VjId>?pj>Sg>DFH+yY| zy!xGVQ0!I^P)%EKr)gnOQ)oT;w9GPpk3-jsuj?zds-XCOa$!l>w)`-@-%;XlJ|Y*j@O)eJ43)@);*k{OoZmNsF7^;-vP)- zyTx!I+4aZH_|<;7Cnj^umfPwTE!O2BO)^`q9qmev<$2rG(PiSH>FuwZfr`_DJ!wZR)Hp~|t8A6o1^O1w*cWSFMMMgb)IsXIKF12*P(sO~sf9k4a$1WoIU2INGd!X6} zX+VuWJ)&sX-}sDlSE*W;UFszaj8;@*^)0S7SQq}z9|l67zCKasv@W0E+OMQYaeq1Z zx$%Lc=G- zGSmCoio6-emiev5umzb?q=~TW`w2GCAx0$1JSD(&UH4=QMgPqrAbvm(6)S~H#fBxF z&Z6Y(7V6htUvP`Xx%`Y^YFObjNs#YkdQ>|(1@3ane1^@(vuyr zJzBrdO7DXWgWbbc0Nd7`97?5rdjDbxEu?*KJ~U$1ZCA%_x!mXUqVF1b?`yqsj5*y4 zcav;ytNw}!)Wm5j(!@ln?YhRFcFSmPj4OnJ*^EB**}@=ipsKbL67uB_*x33J_b1@%yp>gcubc#)E0bWVP|5S)Drr}F?gE}zo2K;D~nX@j5tc)rI=F4 zgMHs`l)2L?3eNKvmKe&&*elX^l(Znz(%=9bk1q(#n~8NZJ18*tN7?v36Q z3-tQ^2Y)XXU^9b&@}s(Y_SIUakG;@*nSWTnXzgAWp?4BTX*2(T_H&7)syJb_Hp+0Z zkMN&bVPvj~2eZcAsFK8p_!td4$L&VLl$O2=L9Y%%@z!1dWxvb{>xZPgSF2u*2Fb3C z8-g8-GS-#%-RH77i}R!XIDyb+ z9RavA*~o1q*QTjnmx7B@mn3Lctcpl)7xy6f9|caH6aV;G^n;Ny#> zpIK*`{*NKhw27Vlrb(-*@gMDzuLKI<-?}zLRa`%RGI**pNt?YQ+OU42_9mzYqlnnG z<9jFOb|KE1i&|$edv((8RO#c=RoWyf)WQ2f`UPG<5`sNr`|Xx9tz{PwhC<9p(<>8E z%w~mXbaH~bkh$^K>5_ot*lUj^MWWxJ!EXRPRN&JpkdO1M_w3(SE}ksQ8|v(6KLuA$ zyB!1&M&R{ecz$}w3C1o#C!N+|Rweaz5_7A7Efrs6dpzmSrFPfJK&BEC;@e7?Qg&b3 zo*1k1koUT*koO8;pwv$nvXjM2kMXxzZlauM46D=cFxl-8E2)xfS8&gn5nw1_pTb6_ zlP$EkiHnbL(|mr%-GzrRQZf7KlcrSm?^i?n1;?93`+_+~F8d1o*EJaxX6vV|(h-$5 zSqqf}$N)Y;)XA(ONyCB04tX71F|TU1Zh`$TtyvYacSvT`>V(y2XM&>d?@Ic@f41`V zF`uK9RlyV`k|I6j{dT*X)?TB3Id-xT00FKGm9HY<-`xB=#HD%ddm_KRW_~w;$8Fv` z)i(b0>P=x{qo+@8-r&KSOk$gI*Zg!|GnvbBG^J5s`s+xecMvc=R0$P$Q9EBQOg=>N z@MA}^4s&LbFo^@SM;O zvHv~8jOHZ(Nn*!K-Dvd3gcz9N0!_+~A3twWI}ldhroyK3yC7eCtWaO&9Ed8pe5_C{ zSKYRYk`b}atQXw2$6gm9-?xGDRcq~MA#xl6J$dK+icXTWy^frwuUa-SsFWH={ko*! zQ~#0r%?3+{Z&P}2*R0gA6WCrmjV(JjN4t?Xb(vi4?UOC!lrUUQ@4tB@U}pgrjZZ1C zC-iH>V_;anvbZ?eQWE%*9x*N-ON|A$Q5;o>r7Cq&b!|y2i5s7LK{5n^4luUJ-}lD) z-9LvxLqU0YT`MVKo1@kJz@0@5_C!%Zq0aMc2_OS^?X{M(-|z0zQMpgpJI6NXMH`Bg zWeGNt0lXlN%A|KM6VEl-cm=#*zzxDiuZ_Cvl7-Ap9#SmP71&K%7X7K zLFkh?b?cH;_F}#2^Rs$BW`vw$E+NsnMFOd*oBV(`{-5M{0YdS&-HZ2c>u+j2EEk*Q zaXpF&-lOZE86U0e^R)9n!9hgA;l=)pg!6DmH=rKU*4T1zpI#P-8YX}&4gVv7W{$;G zH2+LRKtBl{gz!1&lJxZi4=r>EJoMRqq$ z9L7qORxZifm%~0ph?XsUQ=4_$QC85rEScK}1%ORWC()?wCr1W5l@2O$2H>^=s*_b; z~j>^ZtN~4&^PCO_s=6*f#l>Sap{i;#f*~uv~4(6-uD90q#Jn{|B&I2 z3!8-;Ex4kC;Fst;i4gZ}kD@Pn;DyKYYpyv4FNPe2sx@o+lwRXr*~9aSPN`Pfx>~P9 zHms_v)1w%9>5O~DdFIz}5s?w$gB}6FCVwD30*DfKRW;7#&^gdY82*m%$|L4`iR`BC zQsvs^`MQCp?O|+U#a+Ue}nIEjvy09*L@vqqgs z;IX$+~t=zgMe}jucg%O%Dd)7zT(#twzNE&NTf^2tRv>MP-6R z=K+CONan2Djm^*g~`oe(;2 zk0_ov>A89^2gl373(5$Mh*udZR}SA$^eMa!C$Xi*hei`^sdc&U1XK*_P+SR+cvG3ZG1nLHe7tX>2!9cp zb2=NlU`U`yxd-~h5!4_c@<*-Qw-{vHK@gl;f8{j&?|wwvDr;9O>&wX(yZdb!bB_{q zxd0yyQ>-jU4e^&40tajX>aa-FEGB<07z}QE>bK8E%H~~Vo0lMBJ^ZS6YJL5wNJMdM ztzJ?(i}xAUSAuiAWUuYWi&s4T8Z{vZpg~+iuVQvr?VqmMX$tlh6UM3*?qiA>T zC%7Jab+VtV?=@d6E@gD!>$vjl3U})XS*Lov_&Jf&3{DIT6I%o zf17g$TMWQ&N6{JKl|@$6qw&t8Xd;onk_&^_fMW2B0BZUV(fhT~7bs8`_TIWH<6SrZ z&fGw7f251nT?8)jB~9@?1WenKA}PMBdf(3;I0AI~OGU)rmj2()G7dgA2`-t3C&jLs z-VB#rUkuPaH>>&-cMxab{$CF-WrSsl@TQVvNdK;uh<3pHQ#zQ7h|hm)hFqO5*Q#Y> zJ|I4w&E(_S{)HnnRS4G#@!$JXCw_;ONCZ$InJSI9yYV(%A{1?fa$}-iWBKz;|%)bu_$$biV6!{cb{=a(^|2Yzr z>&7Yi|Fy~?2C2oGw#KlgHCKU8ZYVY)7Kqe+6ERTde{9S*9hNw;_r0pF`iNJ(YT_X> z($G6N**hvg6a2$VFIkftX_ycgL&>~|*^kS3bT z4t3i_Ny9Ed&*d_&%-bNhs2dv zCI3U8|9pdvAup_u9J6ob69y8zd`aHFm3v-0riO^+TpIwZxM_A%Nb$b?fj~nEFdj1{ zaQ4|COa9K|4o8e>Dd^q};s5OG{1?%nU>44NXvd{>u|@p8Zp-2|*-Iu)46$fpzdTo)vOUs#eGbW0xbZDhP&588GB>&fRYI;H z4%tv}8BZdL^OAq(VqZ7A0h2LzXJ4$uL5E_?-E_6wlB)VksqOKDHiwYLuP*xqm^$Yx zhwNTFZix8^wDSRbP0R26Z1{)n-w-uO0kN<{O$B;1c7K|WMo0#}_H%u(ma&;tDWu1| zD|11+Wf<;6s+^ej2mJg?nGrD#6bD6kfnGOkWSr91D;-O;jMH~()PT+xTVTIG2(rC? zhmF5B5+vcbA3Q87l$}e%I$>#>gGbbSf6g&Yp-GLC4M*jp@4j6*df?Z{TXzjZ@Kb@j zJ`uWJd~rvi^Z25w&e=w9s%bg=+5NNDvcpCGIw)R5FmL*eBr$C|1^)p&zu3DAH5{>Q ze+Syd#d`k?6OFwkMiT9s8R7OH|N0N&yMPmj@jte7!$MzQ#Ac-bp>Q|r-VVaQq#Jqx zA_$|sNFD&{LSTAeFP~3UN&bM`U(fcuhmC_&sEo|`8UdsVuos*5W!>+=!C!0s08h8w z(S8|e-;AuN-42Ml`h`6a@n6vT+ctOU3veK>6kyXk{+i&g~Rx`9gcum0dq zj4B&yM!Ghd}2e0Hf|x!2np~-yZ$zPein!!l1g1 zE<#eLm3M#zWVnf0_mI*&ZWW(<5-=BcMC&{E7NfX(;&1^PI7Wmb{tooN5(n6cAWRb6 zt{kY2C$yg~3;`5%({=I0&iz(6hKeIEsUqgkqA10pp|@4w1l0kg$Q1(tgBowyHf$Or z$ifc~KT-yBX{|=a@D(Ii4^Z*RRP4puS3g3|wD5}y7Ty~Ec2P91R)7ceT$uxU(nqCx z>*jr7HQ`|lJX{A615 zPWN%u3?m_J%>d<2d^)~i-zG$U78v}j?Dln`N2{w8h~PQ^xo>L|`z0DU*oGS7;K((Q zU)oI@NV|;b|2WYjUk&6Y0abLrRUr2nlp-Q>J57DN!Qa{*s2@oZDbWCVN6E*DO}A1= z)&#K4CrTldl4DuL4`p>zqb@Gbc;0HNC*@>$(pd@G9pq{Sk(LcbbmRa zD?qw&EN4eLP^vtq*2}}#!}5Jz?368zYv^L=YQfXjM4t z)}0MZ*6}i5hG!pXxjdNWhYs(*=Xk!MM8oAjsO_dQxA@<^?9w*TB-UI_HZr|6|)+moS1L zBj>GUtYf~Ey{*s9&-3sV2$t~WsME{<*|G8#4E@vR{YBL;%scZP2^9G{$!EEo@$E;F zO7TNKl`CyxFSIEMsAL6fag7o)%;h&Ec^(UX?eL~C96?4@<3w$FS5oYLI(NqLasONJI!usUE&V6<4T{^;!g z1q_iEpeAoQF-5l24-ee*8A?hT+}u92+Da{qHuE<^eRqW`YNbgFNnuj?|A!sFrwua| zy4Sd}rE{GpxV4P({=)Cl*t+my=-8>S>tavo*n{G97D9^ysRp#m9%ESab~%IqX1#Z! zSyjiRq#*B7r*jlZ32P0=5K|~KU3oR%Vf2w`>|*qW&g{|d_BD0zI*u%F^gw_{L%_cM z86^B=OX@(X+U`4RpXspJ;q2=lj=p@qAV*1_HtqXNcbe6_7CaVS%&c=!DCGvGVg&5& zyUBxqTOrd3-J%_Y^izyne}-8LeLGzHS=tC}Ze+>4V~M`@&PjQhSr!x3_1EXEKfQ*e z^sr1dHLKlfdRW2fZ1Zd5eCz(k2)Bv_08{*6%qk{wah57dd6%~Y&#gFY9EAH}qWNNX z`ErBD45e18lagxwaG^Ik{e7T4YN0caBNmb?11#pAf((qUzfBSR zWFa)xmG0vuFs**GkVL|>5T>WmZ=9b-sQW9ZZEX|dum)w|NRG=tii&`Z+<@jK4hO>R zFdUTY`?7V8Z?v~b!U!9GTa(_!MuF@o1zs}MK+IY5~nDG@sa2W`BCrijwQPA)r(EnL_Ex~C85Xl z`+DOw+?I;C5k){r3BnSSz0==vz<%f&ab~+7yA;!7XIyQ>Jk9b8@8>q#NH!=H)q9$w z-W2hr3?*xCi5lovJI-;Id5L}dO%nvrE<3aeuJ!19TJc`&rNOku93Sgx+3Xr!`Omze zI@&-yEDrvjpEZjst|R?|1DOX5f#$0pY@;H4i2+lV`-71KB&hGc| zF{5tp-t&d&LbQ2|qWlD}g33|%?xf6yyJ+)}rz0EPlUXJT4x6E;zkY1Jhs}!pQ<+hA ziowu&!jAtG zs%fp4JJy|EUK#-zlq&%$*OzZ*ZQQtUR6YO@sv&}j@}JAc|8N{(4H*2*N`(8)@A2;= z@2+20Qlsk{?0T$X3$k1TqLGEtB?Aip>3i2|@Eg4WVwyj<1bc|~?R;m2{>oK+ZF#Sx zuP(3j(Z%MJMx(pi+I|EJxlqxZh>w`ZMdnA!tqN;5KPDq^-!O4>%Ebg{0F~j+nqPK% z;8V;Br;2!?aS@Y^_bA!4SUm%C17gOCF*yOqdjx>Qx;=p(e8Ec=e$NA1Cj?^V|CPD8 zr=B)5vb$4A(Y(J<9hG`LsZpPkdbB|1Bf1!aN+0;i6^F_GyQi$)M zp89~^fSGti8t!TA^JY?@yX7IW_u<25z?FBvH?dKEz55^O|HrohMvi0u2SwHxKyfxA zMP;boM_)RI^R?oFhPRhZPmv8ibL=H>l38?h!l?1uZt+)Dd7GkE?9_Uof1rDByzmm}cx8MFm8P_P3rde;U;*wp zJ|idYrN)*9gyUHoP&~U0a6!1*jYfg$oWSL+^n!>ewnn<6t^B5i3ZFU7OER@Y8y!m3 zth%z@y1e3`)P#JxynwM77uC&RwQJUV+4!+GIGkUuVC%}|D*tDe67og!rfu(aT~tA7 zcGaVX`7}Y{WQ^y13=2v2g)OG+N-~l)Z&2er@-1 z^PWf3P1pORL?6)(s+dpV=ae>SFwGe%^NqQ};}@$k)w5iibs6=%ay^6}n+ek<2(fUF zY0j2^tn1yhGi<%B6-*KB48`%P4?WMCkx*yN@Sn%E3 zP?n65WKZG8+1GCwScrE_BNs?tG5ZJ}IGegMw8uWx@A)=8r#@)qeawfvU}#btz~~E@ zf57j(tpgGuCvZBbAjk-h9fq{ao zJRPsT#!O-z2RgLU4%VzvVY9A7u^j;Z;n3$u+(J0uaVmi=36oXk^8RA|6Fnv=Y=yXF zZj5ar2Co z;&bGmB=4k6Rs+q~+vh;$#{QOSjdB-y6J|$Nw1Ra$#)lWiA^hDf1^CG6%z&oXT^)qq z3jaaKY<0_VKwA_uLwAz>8AsllBb(4(#<q{hQtJz;5*O#b>dU4rna>WDnB-zM_N_4OUk6zhj?VwH!9G9n1GA8 zY41=!bKItH*pRRm;1<1K|Eh}1tn9aab2iu?capetHZmX8e9 zg<08UM=hKAn^vptDFe-pX@^g@^!&>RNOde^A7ipu@T3b3T4t`u`9My9CV>_T*6#li zD(FJKv0EPrN1(nj(TQN*(N(My;_cbV`TS%kYwGp5(##kYu3r!uP-||>U4z`pYZ#cf zaOV0)ff)EPca31Z0<*t0G`g}~Uo54m5$>Y}+&U6g7Zl|CS$7prj$YG9%gdf%Fnf`) zR|_%Fs+#6#ft$@7Nzs9R0FA%cm>QF};W-Ry0y5~Gf_gt3Bfaa4wrRJf@(w%dZH~-} z+qC{yFO6i){co!cyFFNS&4q(6SJm^-Pa%?zZ^|!ukAMQ#Kh)sgGL(kySEkwSE1o*C z^mz@@d)VFLO;dzw4vz#@GLr@+1yE)K!i7v;um6!0IY3q0~Dz+qP}HV|HwI?2bGB_dDmzH{Z;p&tJ7G_3T}#=k~(2 z*1GN%pHadjQ$-pI9&|n04RS(2DJ(b^Rm4*(_P^TuZ+(C!iTx_eQB2bvh(IW;Vq8%s zcV+ztKf@AJ z_gy}v(wiLTRos`aXyuEl3(@)&lM9q;Y3qzESlP()FoFMr zr&2@%^x6L3krKtA-dZ?cDed~TJhn!i&9$|o`(z*qfRd`aj;sqMkE?ft?EfFyQww^r zRU4AA3v}G2^?*K@nV(F^tX`3il9;M$yLW+;yR_Pf1kn$mx*~y-cmHVOp~#B;#R{T+ zEQ%0R)|_NGY9{yD0@~efvU=3py7q}xRFTm)Fp%)WF4i|mQ2$DU@qgYt&?M-BfkG8e zVafiEmqh#K_Ix-Vp4?+s#{TH0*g!=A6G3B_fuhG(z8<$mV?y#5R*?UkYef$=LZMXB z2KaZJq!PC15}8x^*XFYno}jm%?zc6K5|@1%o$*{`$mdsjlIZOaj0FL^g01#s$8=kaAiOFKS9T#~y(1w8g*L6)R1m|M{HC)&^Q|)G@v+Vm z`mQt0W&#=LX#|EGjOf&^*$4pXV}?BG|xzVqy!FBaRI!UYhe z3_wz5BuVfGkckLqfo{-RbVmQ&C|S|VUJ9oAZvx`K_C*+jp>NX&?2za?zu{Xs%5QGN z?TZ9jb*xe=7C6ZNT$ul38T==DGfzkK%+Nzuc7V*v0gfWoJC5t;ZWLe#yqjnPqwwRo z>8rU(B@yhlfi)C21sorXC{ZcH6=?pV?EXed6lNr; zR8d(d;I07Hji!1G!J&NA4hTB5rmp^!4knhUPiQ~OE%Ido065Y#(a;=8bsP5<3TtL+4_x2IfTkSg&*J{p~Q|7`#(+!BO!Hq_n69##`YiFUvPV6a-l;3@EkQ2%#c25^u>x1g5T z)XD`(=u!We$7A7;WxS6bg|dXGQWz^MKdLDSo|j<;f&cgNe-{t&{p{#t8#5JcgjOLki9Ui_P` zlBf2p9tUK_l0p8`_Anq%FRwb)-|jP zPyBTa?>VuW%XMvH*Ti(QRG{^IJiGOMYTcJW#5}fFfI5mUa+@M|+n)0+#m~w+t4W#2 zp!mLrS1Hh6NYF+#97w!t{{oFAdLcA}`oV|=Nq~t{D>sp_Lkxe8FfC^z4lBPnZW}PP z-7jAy1m9ldq-3XkeGceWB?T*DRwlQ(e48OZGKu&C2NvQF z2ywr;2!w3bec;?LAOl(~La@tVkiWnVcN)s4qZa9Sw{-L1Rz8|_V6fA_7c55M)#&)X z9Jt%;fIJ7|F}~#A3NxX|AQ8?T=Uz)7=alMKx@mqqR`qz0QkzPJ8MfyG>!-lUk^WSh zXXO|+@NXi`W0gjJY$#2Ec0C_<-(D?XYu&e;Pd$)2>S<=)jhQc*?iWJNX*qePSGK%w_S6H>Z+C9slt5C4MC9=Sipi z9Qw}G-09LxfecM^Er;{ zPIM^kg8+pR70=~0oJI#dI%(uBVuRFymeHwKPq3M3q)j3P25UcRF#v7as;NX4oBSKk z>SV?uKGhdR4V9JUSsR#ot&$U{$J$Rmmq!6iUPod8d7y>!Ig|5oNSL(5$%+}I z4*qC`f!rPBvFk=(u9h(%9AF?rWrk4^$Ox>3N`B35D3WdR(v;q0>P#l{Po8(A^SZG& z`DtuuUv}Qb+lF$&jqq`AVTE7KLV_A$#sYc9a%78`T^1xcN>G^;QAj>q2>>|ebOa+F z?OR+v$i{OfRq}Z!angh4*ta?BV<~>&dxD-ZlW$Jm8^GA)a)V00bjqYnXmt8J}Q1&sp7`1I?|`ITNf81z)Ko(O}}fBU^WO?Co;s ziCy&Qdi?t%{LhJkD)3h?kg>emuNcyP76v`#5YxM)>M&51CUr?5-%wG@H%R|AJ{z=er%q66 z^&5_&4}wPnYvhZSv?V2lC$mh!jK%Ses=v&K;sObnTVd=ST}0jgc3*cI^wr&jDBH-5 zt(jKN-W~OjfOaLe1_lI~DYob3!B!T5orh6*71p;x>LUmm*j-5|kOJzSn}ve@4$;$r zbFF*74kvtuOaR}e3j^NyDLgzy5piM*GVsPRVgqzgJs>ZJ-{u}K0{5k;38U%r(zg|E zcs*zR_@Ufz=)c*hq0Kltis9W#w2O)Tw0uVtA4z3u=2K z$~Xu2cX%BxMNchVf!q$`Z0z+64At4*s9ek-e+H!Z<_X0v=4 z;>Mc$FyrYHNDQduc~E;WXvjXq#{T)``1!lrhGXjy!y39E)E4KpUzG-*ji3M|qGg1b zb8VBcxytiB#oWZJ+}B}Z#Gt{dUuao}puj}UcF;wCp+l@fjrVA<_+i97$DnGQ4FE*c zP^O8^=|&=uv0^5M_QSEG$P!f*Y0kwJHrd&cL_oMu*rqI|#MOJx5b<~p3R$-x^Qvu! z+8_i(4J8yS+SK-TDAwQ!gtJX~)2YUV!=AAU6k_=m*peA@Ba6A%o zJ1Q$1Ss_G18t~p?5#*!Gvl1mz=hf2xtH$*I3=KtG&|GTN;$-74$oHRwN0pXfq(UkH z-Hg`Mef>;bIR6$o=K%|35hY<#KTKe%w_O;!;UhNgwFf{ZHut&Tn%0cb_8Ciuk-ljK z`#caOB6j6N3)x>VMbONe(AJkg!xxd7}mlsg&E5b%~gY--SK-4MC+4awML$0oBcWnAeT1$4=Dab4z#94Y$xXZ1aEd z)UeQz%<)SW?=J9s9$=rV6Z<(t^_0M^^De$#W3M^mTaCgFm^=YNmjo{?W2YlHo;Yxx z7P5Lj@Q(O;*d?9%j4Wob5E*YPk;RcdX_&Q#$kavnJAaD#GK>4p|QwCuKCo| z@6bTmL$II;;uGE`%_@vbw|Qchb)$FNX#Ljp`BpCO?<(AyqN(Gw`)$R|6ff6&!CMH? z-ri$Uns{)kZQs|Y#7-Sh7MJVT_03{~NOK5e`*JWLK}_&)Cz z)&HvJ?Rr&H)!1B5RpBeD86v;?p?Uz){{zmF0Z%9TI8}G1#!KVE&$uGbzt5JM{qQ%r zSI0-*lEO!?zcR0^rTQ4v+-l-+8_UarJex1?pjy4((!_D0U(hgA%(?1R%xFA}pI{Zw zLq2}o5YlH*E!)>9wO3oz<8Nvfaw}Unt8eE|!zAY_CiJA7wJGlUB}9z7^PD6!uXyhme0b84LJ0vA&E1x2U~FbrZZl8$-KX{|#Ugqt=u7qPF$jI1R;;D0 z!Q3*YXovBh@d@AW^dDz8g?t}+CT&)LB%{KhpLKXAt{=G(9Q@Xa@~+6FLc8^iI-)zkqT@FTCZt;dwt_YTPUB$j4`R`bmh&^*#mv3K9d+IkC zI;43MTb5GHwO8S&*#nm|sBUATF+Mb$L$d|dPyKICgnpeHxHsmW?&qtAqb?6ocxxRo zX~!{kkjwDw@v15v2XRe%NGqMpUh(G;WKsHU(GXs^t5wxp*u@^kiUVW!%_1@9q|TvO zF5g)2Suz>K$aSL6cGuPV?o+DNWHL4t*||E58@cE=Hj&@|B5!12LR);e=>sD}q$i3^ zyMRstJI88e*;GM?7^+)mfW)GMrAyC)=qjo7*iyYq03Cvat_r+ zp~Ig5B)9zVsBWkDZ)RpMajh#dOwreVOY3IUH-yENyo9N;hvQvq>S1qurJZ7_?Pk67 zf?i3pWF#Ul%uo9}U3G-kvYF_RB z#qWL-T)Vll@+R^)+J53eKVCLXYK*9$C}4qXgPv;|1ENAYqIp6V?eo0aa(I-?xxA{j zE~q+)8We|SF&TMinqrfMw`+u2V{&}5GR(L#tdL^x_lYr2pUVwyk|*5zmWP&~Xvh!Z z15po~?+#ns-hMZaV%dgO_QTvE8Q(gkc4H_T%>-#doLT^(1?q5tOVf_E?qL_a!E&bQ0mjv|)K_lAMpzY=NuFlIWs6K8kMjo0? z1jx$;s3m4|8~TXy+_L9j8ypL)iWFSK*27HxId5+tUXE|%Ca7N5EB6IcDJ0l&KGHM< zSh@J(Ct-~*vKwB4TKO7H24+aOo1@O-7DQWkPK8-|MQASOWe>dx47}g0ocG5Mc@0>= z@Ps~r|Bu_({`D0jt~YLD7@zz7&td_kh~rh)(EQC@bUvR%Xwy1Gp(&UZ@^vUCx`&~y z1ZxAS_rOq|Y^Fb$Y_sLqsE?W9>uo_3EaZdDgLy0xv5=t^_^$vEvBFa}K4IA(Hz>lp z=^!1o@c468kTht>W&0Jq-H4Ks8G1)C*_zh;*86+LEy8-+;ZgtRhB>223D56&0xk3_sxmKnJyc$%$ zDbze5oq-?pTc1DUX`MP0%s80n>QzN^%Eji< zIzMzd{(V{+v2Pyx1y-?OwMuZpuYK z*ozR%l}P#O-5NuQtaMdW1QCBs4>7n8uKvAYzd*-AF$iX=hB2$hs?2p^h7|X8U&74` z@zRB0-I8Cm7SYk+5>7CRGz<<|9?2k9AIpp-Eb&Waop_a*U-jm+K9WI~^P+(gI!U zWhz1lpb>(C1X2Sj<(I$4i*ypF_s2<=&M;Yd!UAH++HHtv)@GRPif9F(=L#! z?poQeH8%*VGq+B&*G@CntnF{t4j;5}Ti=QD2mXGnhA5})T)q1jE(~2QYZupa3|95* z?W-PI)Z4yr7KR|sC=v1x_}W^owv`}W@d#S%9f$kx6;b5vQ`)|mI@Pn;%3N@SF3v=- znxa+EC`l&spFb@>yi%W*L%X zhnoK(WSH&9Em&{GeKk%6OIN8i4+gKfEhX*Seag)_>IWIC)8wKi4xNa)!s zGd;t~#{3lIqz_86xQjgk`@2sly%4l0BudIRpNSG;qrtIDy1Eqlr&N8J--DA~KXbM^ zj_(`%uD9Ks+umQxlFYy?gti@^2T};e(REri{26sI+Je293i6$aVWr0*$@Z)>IR)w^ zQh5(^bNfBkYPW1>3?fqa>j*drST+Cmjb;Cc?%q`J>*~o=c^mQQMX2)B0|1v)<5FZE zIsGrLWQ=BM4^_h2lKG7u5S$Gu@*RYw(6Jkbo4mDS0buq~!@+GJLtG zom*iq{@6$xOJj3=xLnJTzDKcqX@X%MPrTU`#TPQPj<{@2=3U3sh*-B5$B|h8^@I>y zXB}aN`=pXAW$hA^!t*Py^{*&e3Z$5`*_fo^jqW?AZ>2CCNSd0Uj(z?#a1kJnep{w_ z`tL~(go?4X@*@Smq&jGmL!>Uz^mT8C@jliV2eL$Wj0c&2re{rk)1fP<{$LbG7cIqd z-AAYqG%ilai+ONbkw^)iYBr!B>!Dn!WFQGIf=&oWv712Ak-(Hv2$go{!x)WyW`@5{ z98msFnk5;}eV8iPZoA{MhbD=dgg0>TwLd-g+{97JB8^?9_E(rnX^-0W33K=KBojeSk?U=-q#H48g6-1m=pww+;5$eGK`sr?4i7A*P zq8Unn8Zkx2+?R_;Ydq%P9aP1T^({M}%6FBPg}E|T<5;IPbxyUht?G_hX5S=g4luH{_EXZI5CAhzU5j3u@A1)H%IJ8$pmiJ${c~j@0YZB~&qvSBYtYprAzmaXsSy_$!{f z%!}P|{1t>kK%Q2_VqNBeI}-w*iifjUIV5$Mo&K9wHsV93j}Q+<-iVxt#IdiVd&Eyp zt{I^gu2S^WANV)fjKf8EnNqet02FdF+)!HKQls*qNkz9_0jWXhUJ!x5G6Q3!>K-WT zTo2{H&!CcU$maws4qL{PTXL7~amd{KYC`sk0X-M1IySX=Lm8)uMNHu%gS!k!Ifhk1 z)xaBWsdUIrII@&TvGe#9$4xGRgS5;D(%$dCB^&Q&9~#+BFq?uKv_#u{q7J4$=Kg)z zTwXU>w`M2bv65f8725&-P0k4~_Ql+2(RH&E2}l>a>j!KVEZK>1`BiV-tG3 zhd89*fO00+=DyjxrWM|n7*@+?ou19ld388RDhbj!o7XF$$mgwH3_4s#WM^W8Lwh)K z9=SZIQnGe{D?#NO&B8tdwUeVRc40egz3{+g!4e^^6h$E@B=glVPCo{5)pEiE?S^oP zZgU}nAfgIT5)Jon%}Dl%nZ}JFjd(wrsBkq1Rz}RxL~X2T8SsP(9171!n~!Gv6B> zzZk(D-(U>BvQ;gwYXmQ;hvN2o82rVOGK>bNS;camm?~qatcGR}ZW@#^W#!$${Oo!( z;;%6GH1aFaMh2WKWc71SfQS=3ghwcZj^$EBVBw4z*Tb~sm>$bT$xS3$5syzcQ}a>x zPuqGE0&+ZMS!?6*uzK^`nK%JMKInw7Q5!jbc3r-GBlJsS#jNYmYw89d70ho~&=Zui z1<{=Wx?dEO$UpmBDq;R`#nq|C)IA8SF3aDrbXrxp&n;!YJ)wFmU3G?%{_KMqfB=K( z-vWO@PZ3LGgzDCmak`Ja49k}mwnXaI?su2E{kdDQ!Sx)}eLc(#vix5z%fEb=e>9;i zROC_mSqnC+WtH*4Xn7KJOF22V*tSq(D40QbQ5eJe{59lk&@V-^vN0rG3KPP3ZR<>1 zJs>N$^(W>8*Qw>yw<`eWVntL$Y`!J9bzGRlx)jt+y+=N|QSqK73sVZ#h))2p;()p< zhS2$n2eYOy-^E2kh?^FjLBxjZ5)v~S>&7Y@?RWTfGvpxK6j2RZc zV7;;Mu-U7xKvf8()SH0C!kM_G-}Xw7%_U&*O@60bds>f%g}FcEyvA%2AiDk{F2v3gomd4px= zsF1TGs8j3+@p7pMfluO{&+X~P{TJVtIKsArW%kpci&LL#Qdp?79KpvgTHO!;mVWa)}Cm zTZBI-c(^_wqioVr{=Uu>M26aO6^W}#125n-9iK^EH1%}s3cVb33Kh1OYntFak!yQv zcv|0T1eLqPm7h2O=I4gOoF?vPyI?86py%@ymu%g#Z`r;+kE`SAZ<}F7MS+-y&W3QHHzd=$jH$Qh$aAykTJ9AfdRP1V;I54o zt5tZh`6NCQsE}_NBuficL!s#sKj1#La_)t4o4M;^G;bWW604!(3Y60y!vOas%7JRS z@fAbNZa6hGG~VC+%;+TI7wnl8#}11xm4yG8dE7`}t4J+X^BcDJjsJ%YI5^~N$LJ~PhA?LEQJ>8e-e%g*m-t4=m zK9&?$b!m#IUiG)>hp;wCFf$*7B3l~XUk~j^hw!-geXxJr-e_Qh{Sh>Mt>}Jx#nfh`ZF?hBdv$r(4eIKF=3a5SVlHr#>Bp_ zL{pS&7-x%rrv|M-3tdxu0;GviMMT-6s$?A15yf8^3t`~DgiE7+1)3iYf}mb=@P4PG zy>N3|@(fguRUZN@oY<1Z^HU-0#@ zHDTH7%U|@>C-8eYe{Gh*Xn73U;{q*|u%eN+Ji6=FYDLq*d*;X@Vv)^&f&rzBTq@7+ z2Ti7`?$CK6Q+Pg}RDL`fDSN#%lVU7dCACGcb#+uILUa^IZjQQBIKNJX#&Yv6cVuW* zAm+$M#B~YXfBRetcpgD3n<#$Gv679*G+W%Y6Jvbt6W3*T7~kS=9C*!F}v4JQv< z?DwviI9{O~k3D7r0YT#W1ZXwPWj-NL5Y_I6b5+Z|riogjUYkyK!|~kfJ|U@zm&y%C zv9gsg3kJ^0zX_~=J?r9u(`ZvP2-GRjr**pORtCcyRJVgeOTc6G4*}dKpZ9$hUl`en zO)ch1jR9DxJdn{~%+OXC=|vvt%2lS9c!8GJlBE3HjwM+)qeGSM0%p}e@9Wa=jKT4z zH^~{P*a>~XVIrH%jE2c}j?8Ez(lZFP#$#6leGmg=*|89hbL?id$*MN2dRx5E-od5` z3Op_NnLkBrd=He6;iMXJp@9Q}G6>?iwT6OyKU@u5D*#q^`8D0zMOba+{qoh89ku5k zwec%!zK(c&SU|}6qe1o#3K<3@kM=#Yp&3Q`WSaBIyg>~$Hg>*wVy%iEBqv+$noHNy zFrSVts=Dk&A+pc~Avc``Q0s8j6cSn|{&GglYIYF`nD`Z)V4-G$;pIk5zgBny-?Iz9 zUm->L+R90e#<>=jm8{svWI$h2OVlsy3N`rK7lLd$#(xP;GuA%u%0gJloWl+!SU%rw zzZ3&O0*HYHqEazpDav*uSZ{u>#D4sh(j=VybV;e!(WN`&=l*vlLMo)ADT*F-shzo!W5o?@$8TE1>PqC+FjJ!t*Pvta;=@{!_L5 zTOD~!pnMlSq%RxW9+YQ7_MKTjNFy8L3@+?Qj&nqS8w3SK@~3)d{(v8CE-7&fffI}h5=IE&S;ypRv636SigIP9B*e?JAhD*1O5`}vRjvf<{tnQb|84<#-1nY3O zn&|V6=qc-0Fi8$XWSPp#6Bpo%eHFOSq%{}IQhvSMyx4l#cV<}p0dMdyj|!`S3Ihrv zkxPl`x!)1JV6|5dk~Z1`PWZ$9bJ%qac1rEnRD*-Zi3x7~rXji?j`0H!8nS=G7AeRC zyHe}CI8cM5n0I?2aw*VyGEz8l^qyu{airxJc{JLpS*EkSk5-49TfiOo*vAU2R%4^! z2SV2j>yL@|RV%)Yd*VqT(M z4#*wcYSg`Xm%P`={!9^&y3XS zz??(p5~vuCm0Xzzvyk~Bc48BTudEWw?y`XYq96+QoJ;$!xVEyQbEzb0DAL8zbn(EP z$6eqFb07k}!iV(XpTiW_MyQ0swK>Xn#k3C6`-4(Po2r3h;VfXMd5l2(k!<I60 zzF?+%ngLDE!$&1jh`Niic=1I8HCx`4sun|^)H2!2I4Kt{?nwc6K6cv`*Oae_46AdFt_0o>iYdUL z-I$z9R=jPj`73Ml5`7SRSW1dVp~nm#*>y2WV#i~CfkfPNX=bQWL>L{hWzZfj6M!{{IMi@WfED5fba-?c*^!rv7WNJg%Y+2G=xJO|KT!$!6(heqB z(&PwcL=RM0_BjRm&Ywh2E)F>i~e+FIewLQ!o|;jV>R| zC;KE{4c+z^nSk=?7eC~`Ky07DSwOol)2Yqa(zHUv35W)wN`Ig_)!$H=n};zY(Z7%A zWz!Ah1&BWDXpuP=Xxy(#2Y%1X;zEz44W*eXd9V)mH0Qj*(8{Z@K$B(q-79Od`XUG{3+Y>MF%-a<2 z^WR+PK?TEf`UK`Dkp}Rw;|Bv!K%mKdR|8+$4;pR_4A+j ze$uN=b=__oS8EN`QC363Q-MZXI!Yl0FP>up*p6L|g6T}C{U-*L-33^J3M@k=d7S0~ zsI$6~=>Yk4WJW>DWnxCks_DqlKuOg=I^AiQ>q)82!Gy^>MsB}_>eM)TCJz($H;G;> zPjq@FkJYt(TFmF7LYOkJ9$?wegUd;N$OxYS*X&D z1zIdL8n$6;wt?MtXB(0R(HkaC)&j3{koLXL_n;Wn@(8i*69OqNd?8JSxdcb#ykr3^ zC>v6ZW?7anTsnjib;j6k1{XN&QO?M$zr^>%*}A;+&?=q*lS->5OG^Nm2_49wK3Zi% zpG&mouj2txG^Exe=-VI9QsOzK47joQdXv{pOsnMD%L{e}*iT(33(R;7y93~Vkq)hB z&=!Q$7rq^mIFn&vvQ_5;U%$xDApyKs&WNMvn-e8YDzuoOk=NS=Rna=Mtj75L;Kdw& zB%{Z@cow2h6NLq;FvMbG4=>?-{}E8fJK*{$xlQdeL%^HTE@PIgO9DIASReg2F(Pq0REeGDXsDoSB(T5HS@50ra_yYMQjS7$~L zphUUgdkA2!JXr47f*J^W`M66(vb^swwP|=242O=U`=YTQ+lil`bp63A4Md~N?1Yru zsie+abR%>{O<^$-=CzktWC1Q#aUvZuPxE}qZNY`jyodo^va-0($C)&?`N`B#Kgh<| zqJt3_`hU-PXN8+;{*q*F^H&VO9Mj~h#7O@>`01OD;9xpPn*}q9JSN3*piF#yt6nK7 zv#PP%tU|!4r0iGb`rO>nBIPT!D$)HsAGFF`Nh{GGrE12TYvww6(&9xk8dVQ=o)`P% zR~CKuhJV_B^0^$U_w?O5R&10I>Uka1k*_AKm``1NJ6ONi%3SNJm^p>6*x=;ftxuXV zp`2BOR3&0k0lkV7_3^!43edeZE=+tM=oiEHImU_NUzmN)(GfstUmtVu{F8gJO& z-l(L)bvJ4utH1hPqXCP|qZU}D#c`{`RF7tmvg9*DHM9e6hl+mL3b!d*C6W=wCmOoB zx(m$n+)GWGp+uOEaUshqfr*olLgxvx>5J0+cY4GLMB8p#IR%=FY3S;=CQ z(M!$pL)<$wfcI~j1tcZPuW2jipnbDie|}?ppSO`z;n?%bS8}38w>$$OV}^MSg2rEn zimoRhIYny&V1FiLNuVJ0jtylkG2}?Bwb9aK-Nqu8XUWx<<6NL?TZGIYQeSD68pPr7P0=8ND9W)s zH3GrNG*@L)Mc|PX@p0ca)C^?VavH&J){yp31mQ5jm4wuhK1_-n>$E=0Y_uKu^qvj* zRihU(TuJU*adcCC%VtPlAyn=+X^cVT(R2U>+X!AbCrqXe(TDE>X`&i0q=(yP+I!?#oOeE>x@Wj(;vQ ziKh3JOTI5>V#oh{Tk&m5Gy_c-UwC}H%4aT4J3idV)11e3@|I@S^;4VJcY;OH`c?>i zo|YdbLy!H*;uL9bR~P4RjISG{u8w{e6$%1(l6S_TGle4{l%|bB- z5eMBTHQ3C0(YU3mN+Me|&EnUv>~0H9g&3OCf~-!V_?}qT#TtP1F02p+L7NuGWh*(# zyU~Pu^E!un%fR+vYNue<`Uj3>xAD4g*svlseR@x7P;am|FJ>wrT6DofF z0}Nvo`dSg*BauTMg_C)NtT3PT@PH@#S>1sGd{69W)E(B!sNBhqm8Id z>Bl?Y5E6VkrxYMut=_u4pj8xDa?Ne_)sydx6F zPjWugs4-70n+bI>8#Gig+Vgz!C5|u0p-ooA3(^Br^=Wtu(qfL4V1A2^vgKUOpVBk; z>hDREF`$u}5xwGS!>ITf;;s&JZTq%kU;gOs34m=f`&MzDbJM)*r(LihW#PoKkKROO z=OyZ!37U}yuKUn5WcB@iZ}To-K~wuZ{!^f6Uma&2`dB^OUEpy3=KZKz8ch13*<7z# zR(x5|PzgG+%@Aq)5Z2%=0^t<0p~vm!lrm?qp6TOU6F?iX=GogoWCwfGj+7E~u{;AI zrgv%z{+;t4UsSbK@_8A&t)8{p;nL4*W?P$BGq$hnuQKp=6QmX^q%r*{H~Yj8`&4{z z8DeD2oWoK@99o$+!qJ1Ca2T#%jq0?{(;w2e64l{ql$TKx>QVGqa8H!d{SNHN!IxSo z1U(-+3q?kZ7ssdYls}!5v~%LpLbIAEsE>&^zlg?j+Y)*kp7M2Fw_0^rb-7PI6-Lb= z9gRwOX;j~J*SBdP$R1N2M=%=DRsdxl1aYs7&-3b$^ud>vKc^OU8rR+BL8u=eZCw)Z zMg`V}E|>3(jC=ReeEJqbuHFcS(&VR_cSRu9Y!8F+UDslsaza!3pNq>fr5t zO6@Q)8^uV!eFhoL_plfxbD9MQqv1D$DG`FjtalUAb9+dc($yPae6(s!O+ugI26r+I zvHok**hBu8e5 z^mFHToHxg5J>Sg{cnt&9XV1kt?T6sPeXdyl-WO=ueaO_`=rMzVkAB}q{(>xp$oW6@ z)X`=x{ixc(LB|drV&*Ixl>6LyXvXt?w0>`6=w~TMGMXR%8EwhNHZ0$y%pRObrx~uw z`%5y^?3sGid`U;AxjbcRQ;%mp%c=}>pa0OWGHHY)aU8Djv2YiGBGxM!9h)$GPljH& z9|52Wo)CiBXock0blE`&q#oLkFye(!>c?|Nad!Ddc|7T=2WcRIcer<`{i-n&BVRo` z(jwmv*6s9ky?hwyAsc$b-Wx7dZn?6C9$({+q8c8y<>0PGSy3j6V7xF9b7Fm=g0=e zWI&~`HAP;A9KI(4b+D2c*yX66_Y$4_CK6}`anHkf_cz%P9TKnxV%FAyHlLt5N!lWVTreQ}PP=fU@#RYLl2{yP|| zg~grpXHQX3$ECLpEvK#09}BBeW^U8QVmJ&Gh0qX3beRPVx}gwKrAv5wduUy)!$TO? z!~MUVUkp!n_s22HXMA~@uw=!Q@ITMYORXzPN481zDd5%xp~i&_LMR6*0QC7~%`vp! zU#Lg~6yW>BcPjTwgLMN=)H1p{xE3kFSQ6C0*js8z#dem44loUji+utNT8 zgLgq})`K-?{J|>Bq!83D2_7e+GkpP{edWL+BVaa9V&&qc{Zbi+=KakXzYRO-f=@da zSUMaUkBuZq{-mp%=87pP?vu??dMLM0NTy3^g1Lzw%Gpyx*ZJKAT3K>kq-+bXZB%^M zRNvI@ppyeu$}T2pK1`8c2*7H z>>#ZUV^y1-b=kU1i@GN5TF^5Z%O{#@Y>PC*AWfG5Eo9c<6_=W?>v)~o;=3z(7Vi86&D6zqYBAWY(wV}y6Sa4xR>xtIWUn3vJ*Tv^@X}s< zaXm&`%3M3c1Q`C6FRCxWcfU49!znH8s;{5dxoGm@*C(@>$+7V=4$LNfxFdtIlc05pnT^ip|n`jV-)E;s`xnJgolqMd1 z!hZ4v0s80tL4{>WYtPrB=cLK5G5;Ly78%N(o{2rlizFi{NvTuS*uY*ABnwTZ>Fd4n zJ$%2uV~rt_`6!%^!-k*0DadMj-VVv6>fzYRtlPu&6qR8!ct);I-J}P>cnZxIzgN?* zSXfKzs>o4rNclcXYj3)ub8~Vw<}RX*s~W`5Ytu|$eECe!2SUE=Lw)*WRu6PfK>;KF8SE(1V=i<)M!t;2?@OM_A?KTwC)hV^ zme;0^s-mK)SqER=wx+qQmDT(b3@tWodt$Km6u}D@qNP~2#vW=@H(}L4AAK6kq@{}1 zy*=7JG#`pGa;<3skEcXZnH-DAmI7;uEF z-Z%BeKWjo9UB3}u=@kD0KKl3mLf%ehZt}6_==l^Y>*T# z8b^~YAG4;%*M7f0y^E*s2RrSUq?pP;)G9$n=%OSsI^GLA9HG^4TS1}+#O6`3 zJM**~u@iUbLuCd05tANJe(%epWd|7&{2S{ObhO3`O&!0`+o0K@ZEP%Hz)IOMb*4}` z`#WF0Uz>mV*R;l|*4=8{8s%HN$R zMB~K9bgL{^t3RaUwA%7J-IwFUR=jriJ3ixl9p#5tV3-9R^`97NZPS-t>J*(hL!gJ6 z0QCO3WJ+zsdW@GefZJox@K}|@LAw9zfJs13KBKRVvX|`O4y=b)GqXC{unoek4x4$R zNukxTwYazhj|!9mp)g7i9n~pO1_B{D^Z$>oZw#+9>b7lc+h!X#HkvfHZQE93HMY|v zjoH|?ZQIt)-M#mmd+&L^^hti?**|vXyVjiJofvB@LIihSKPp;i5i?YH4ztxm9Ffmr zs+uFS`4^WlZ%3-{b&1qT;JBR;rFpf=DnZRle9d)Fv0`;{Qzvy?l4p18%MP{=ulwut zK$)Sw%kiSP;yS0F??UQQ6hv>6i#CyyN-}*1qBCAQn?c}BUH+#0cL_U0O{Rh4HTo~B zO>4=3BVK;HxhY%E(PfrsdhP1Cbql)*mv@!}m-n8wbavmT)f^`1zKcJa2^AzLO2k#r z;3f@va(vuPturzRrxJ1b(!=fl>@R*n2eH^aElt(j8<5VWeNvK5iGLg$TVp)YG$o{jNaHi zBa{%6`?Drb95v&1;oaH?6qOFtiMxD#c>2o4*3s0`F@3FeQ3<1sJ(Z0wM5>1wm4em^ zgAJv|*+-#E7n5St2~$kt(<7iX8t{B}kQO*&@M)=d>G!X3mguX92!2PG2SE*P9M61_ z1RE*wd2ZrilcET_>KGESS)+%`MhII0q@O#AY~qi3;h_E*KY4vYD%-m7OJ@CQ8zO;~=>+Z)Gpl4?IT2*;jC`3EHH%HK zq_nsgSr)h;+ceBV$+Atu=TZnUwW`um2#?_Dt{AG3{UCl{3tXv(ggl4RscFuu?50a% z$~g2{vC~QMX~nohPM)6m>Eq+8JDp^rL)}Pf$8`+!)_7QF6qz0ROZxyY~Ra z&li9+#b<$Ezl(CK&@&XIc$q;1I=?{cb?IR$p`d+CSEDBYon5cbx=5kQOurd*ek!*v zdo#4qIU?Q|!3}(^$=t153Wq?l#)rftUY_KJdXECu(*~D^FLfBij!WMj$m1I0W=AOT z6!R2z-Oe3IX$YmwR}vEg&xfb0=X4m_z}1{kY5R9%CD_rHrgnA8)!PdDm(!&TUtanM5{YN ztcEsZF-sEju&~a(TI35I;ddZ5Pf*tIq^RTg1nyfT8KxznFGoY+qWUm`!qY-Ru#;yl z^U(y>bd?Rkp5<*m9QttTjsQiBqIgN=J($@5Bj!zl4bw@&`xOEW7Pb5r`03x=vTgaD zFpHID;!}r4?h)$DF-_7wm&f@;Zs)1-GoE(HL3w`YE;dDeBE)ufplXa99Dn71Kdxt~ zQNEW#}{iBhSbzJu5 zgY(h{;u1=$QOdOF39;x7U6Hl5sf;y`1=^kF9 zPVDU6Ct?9!g?kCiyC7?jt{mxisBF5S1uA0H37wA-NUjl0sRK|J|7jvfR2B2kxu3e``2 zDFXB-67ndSX&~ds1M`}njy0`Hl7}g)cq%quOtaLm0y9)~vF|xU?&V_VG&5d+{>3fg zD<-$br`Xy&7zDR5y~*Q>GJNfc@wtp{X-M-izsw&o>cHP$@@{5>e)uVDlq>blF1!ssS44HIE@tE!yJbH)&gjjgZ|j1fFrci95S3`0cU=-$F32Fa2iln^~&)bqb+|j%ewx z`Bgf!YgK=UYkwzzosZzxh&1o*e1LO4RplR}E%L7?DzTFlo45uNp@QeiC`lsYqC&r# zRH$`GXQjj^aDCE{#T{}*6HTchAl~=0f-S7(bNeWTJYInPe7(}opU=o{Thm-Dlh-wg zmyD?}qB`Kj9Tg0raC@rOftR7&AZfM)VwU5SeJQ_(T1j82pL6_jWndT7DJb&=E_~UH zMdh-lOIhsb;y$X4-%alX>0;wK-Ld820-i88zW%IN;b7F^;7BZ4$I0X_U8Yi}jnK2) z#GedW%M5(@9mQ~OQ|JX=p5f!&`aeD?|E;qsN*@93W9<6m zFB#Xl=zZ*PqBf6IlEXUi1tO2*Y|*7=N_c^)7z5sQlpwJksGO<;} zTh{MEMP*)9NTlAtZ=DZZp#fc445uUy)e%TIR7b-$?dqJ<-~SU^*e`gd@gxq6~`+G?P%`xx;T|GJgWBWH-t1cq5WLH&qM z4vE#DzcWDq$u4;QFj(+j+CEpryiTKN_*pg*=N=B z4l%rlL?Lxg-HaODCMvx9?bPojRsZjV8Iu%|lVuP-wUrpJ12 z+sPOBG5f_cov-mb^19PNg7!V(4~-!uz2ZUkq@EPr*yMi51Oq8wodL)kodJR_kX{YR zD~~Im@IDSOzfKBKy+{*Bt1+}Rrcq0f;CsdOAY2J2#M0bfhSI{;7pjFLLFS^d-9HeS zuTK!{@nxytg?NV&Etu3B7OX4j9P8Rwb@X6%@o&(6^PbsbIPB*d^6MXpkM5-T@8>UI zA37!_B_A36nvWFGc_R;VkR1hXpNWW`1{`G2HS7sgU4mR&j+$K$`p4TUu!%~7j60;r zvts`3%YePKEj#?L+8-JlC7Xn9TEdg6;6*H2n%dURyZ4|jFtH-ulk=QUSOA6jqezQx z$F1o{ICZyi>g6^H9o*O`x5>}3Bbr*VG{wie5G;RtdL{xn?13qXBt2LmUZx^oPlexG zs0aSxU;>)~WS!pIQ{M(Q_G1{!EtpE-4#G5WBBj#d^YY)_eNhYZdi(NJajIxW6s<5h zB-slI-HO(#SKxNF2Tm(AF*|?E4Zuj1f}P5y744-;92yx5JmB=o{s6zd-}xnD`CHPI zXBQ7~wlQOQl|}j&V{OXzuC(vVAbS27^=yPpW&0*N?=}n`^k`t`DkeOwtZ4g}=?XnE z-O3uWAObkHf1YLe0_q?3+^XE~tiAWTi|Z+(JiPT&m0C%d;8iH;kc91Pd^@|emMs5r zuS5np{0J&*;p%My`A|!OqFfI~?c5cyQe@!0WV7ijjq8z)Q z{71hy14gaEY24g2*(RGxY2%gd;V`1_K%0nvM#lH}0gJb`Nom{}T}F@)uD6EzAkHi8 zi`3YUQ+F7We&kv&l}mC8Dsi?oWp|MnzU>aZQN{XYqJ;Es}14Z=ZU!Hb~7eiXTOzfq|lWEt#GUpPsQrP)WIa#TLMYo=W=K}Br-wmsl&t2ak;Auppc* zoj(D40(P@k1&F3gz%m^!BW&n@m()C8XE@h-Hzo)v_nN&L3H#Xw;o%Pj+kX1}{j&(|-V_GdUqD0#=FDG;Nhv>$XyBx;j9bgSd7htvwp6pX znqLdojwi!}gP1L5r?0rmscZg}zfp+_HIC^-V~%>CW-eS*J%B@ZZ)z8T zY3x7}u|RwLJSIpW2ttuEj+1Wrqjuu_E3UUGel9onIZBm6V6gQO+69S|WIbH&1gNh$ zoq`)J+Fv<;Kj>y4z0~A0lxDm`u*V!)7WPc_`NM67u!|UoOqryF_-l%Guge`aq>-9A zN{s}PuK$uU@xh$m?C$nq*n|EoBM;V@2$bBFp%WWO>(&9U(qm+p9t8Vnqk%0h41b60 zg>1sNX04)Xy7PM0o&BH3O6F)muP<)~{W+0rB+-1I2WhwVK0V2|!bl8Ub;kkku!zX| zHfwp;EGUmwm>>h8pd58Bux)??1xq=ujPmmrNCSq~ojiLt2HnUwd!u(M4#SEkiPKb|9jtWOo~EgBU8|lTq)v3s1<}*|2cd ze2sW{v)*mA-6)BK0;rV)BpE#`<*5{jG1yL8mr%(MLjk)Ro#=lnNLXYP(2utPsF87} zZMw0S9V!WrL%NHwU;HZt@FegR6GPL{H=FQ9v3}_UJR^G50QTJi#8iPdQb*nzmAiSc zoZfYmyBy>wZ#i2Eewrt=eQFdcvRgjziz6G(-fs_SjqGLq#8BC$H4TeXfna5fnqEjC zTloH!w z9cHS$RI)ylF|BPmP5l<{-;ywk6J%tEu69zK@_%K#!S(uSor37v%i( zLtH^B^JU6(jRIQVybzzbL8dm=KmG_H*1-30{l+~pax)}0*`0}Hxtn_@4V2LSS1tyI zY<7;N3PBkow3D)c1e+INnBSE$wr9Z-v6|5#C&OoLr@eiE;;aewo9vuOORL^N+siF&d6uUslE&Mmj#zI64|9W`cHjx{7jZa3*0uj>!%Rz5U< zw}&;~HCJudm$Qa+3wp7N; z{8`bfaJ{wDgytm^Q!ye7enTHW{=1FcA)b0b0v})wH%vN^tr)P^@toZ8T-tGbTEo(G zuc;UCl83pPHb_b%jz4&omXBru<{J|TW( z32#!TzPpd^1bJm^M1oLC2(b};zhPnHM~z*S7})p&8S2~2^{e*9GyewQz9D%}$KfE; zRmb%W!F9vC0V|r_<}nmc36+~K&MaZeEwK|8h@C2bvGa3HC=kRxbOJfSPS>-y#_rj$ z*_x)c)iUR<@Se>@8;lc}aGt7hE0jF!h;wkW^tMGOGuwcgt6TC5_X$s*qh!DnN0Uv* zo8*G7D=%-Zna5B%*^~iH+Rw4j90>n!Bm%bP13=jL@LzrcPHhAboGcq#w!ks$pnNv8 zA9*MkP#@)dw2G-U`USfLirjx}lZyNr5e&NUeSg`w3}#nLa0)m%v7GQ+zp-gKi;=>J z&VKJcsO;E!M)E!sPS$p*$w?bZ@YCL0X=>ZTVt2XNz!}ud9^b!pIJn+BPu&}5J&_di z!~)>xY!V?4XJZ04L@I)O`(I!dDMoxz9CM&(>^!OS%6Gik&e$?lJ(TWrb3*KUxZ!hX z>U(cG;-{jjYU498wsHCJ4k*=qBO`h{^rgo3y06Xl?n9_-ybEl==l5yNN$Ya*`C6#? zs++y`*7m~ozVoJ;?Pg}$u+OO^d3MjP_2+fWRHb8Ybh$Q$LAv3S2$(-7TIZU;-$O4S zFQDBJ8a3NHV&w{b(7hHD}?Z?L@odx*Myl3jNRB?+(DMtM7wrHn(egSz1j2 zrqH0Gr-x#NVSpWxuicBWug66M;PG4+NN@Wq;<9D|@{Jq==jRe|aI>~6v;#BUi4Kk# z35+w$Q!qi!e^@S#%wZRy<~g|&JalFzdQO|)28gn-D2g#I%O0_mlQ0V0y$~y~Ue6Q6 zCx6*^8@y=0udgJ4hZHBzw`q7d*Y#eAu!{A=M6!9VPp@dZhk#&71N7;>_C5GqJlI%S zB`7Nf3nj9b55_Nq@d}JXir_ zi8>B|;!IqFOa;$J>HaGzcyD^>Kh75Xk=+$gTVU-ug6V>+v+AtAllMSe*}q0b9Ns zoC#XI*DWFdNgJNKt!-slvj{o=+X7PC-UVUidC}cCL)P1sEU#PGr(Sa%d?4=G*?UEE zz4ZD-I?p}Pu>%(qZ466>to8}$e`u)0q)05ts*01eW{C`$kcqZ z`()oIRp$%p_N)H)OiWa-=;o`VnIhY#a?OgS=i{2Li`Q{6pKB`Lnpe!dnmyE|YS=1d?^f`3%wo{RrOseXOtV#s29w8S~$>OPKJ5&}g|I_Hck7_7fv}p4k87 z#IX4?Ie#O#e6D_btqr%jdcWnYF66nUL)eqBvMJ9x(pAL&_WJ^mrJBL(`6pwyzS5hH zS8i}O@msn=bWGDWQ^Tsq2wL`IuM{mh-|H6AhTBe5&v6VTJCUW!_p1Jqs3^AQ9@V!# zuogw`jqu)E+(4qIQYfNT9Hm2IP@v44A4DL;i+~*BKPs$S@S5O1QU)z-it0oi#9uCd zx18l{$@uf=@zoy6r}ZV@y8`Da;rVU>!Rasv7qq4tj>Fc(kPO`Z)eK{dlO zJ8j2&$BCA0T!}~yF1D8=Zj7;10atmb#Q-F4`OXw ze%o^v+1Zu{9M~|g)wBbYAPmqmn-QImrqT)dTzz@t<7G9bL9F)2z7yL4jMs{x3jdvSjPHS?jCapaqC_BdL1I|TAJIxF5l?u5O*vq-vME{9VRHTaw)#V2d4xHJJ% zd5;mz4UME23Ydc|dBbjiFp~fMm}!|%TGYDZ%hEm-MB9F8^!pM!-+4#{pG%!4M>4uw z`AYjaj2Peb>%i-+q71pYc^xlyyW#A%$Pf%Datb>zQy5L>xc<1I-$@}&e9|&ZZb_#v0_D#}YGqTAsf&okKo|BctNoq?Em8oI$o*3_-a)|<==uK{igAt`dg zun~-}@|%G;zK<1A!JqhD#;UV{WcugC0N_o>ynBx;+1@3pzO)^VNIKqHKZ2j~P=FnW zJa5ussE^AidM{A2m!21%zGoe)$yq!lqm04E?hc`ZpxGgSuCt^ohL-Q!o$IQ-80VCv z?)`K_`<->>czc?sdMy46=Y;z>(0}m6qRUQ5!!%jU+U~4vTaF5K8L;FSrZhhfzeCiW zB}GUq)Oi0|`Wp0bam(-R(m=oM%IEWTr|VrKqeAz9lc!SL=XYGbA87Wo z?%7xUQ>;m1am018dvT#tBr!y5>KW1wE!8IM?dSar-ba$gGguvueHX8Lu1+m$H5PT` zpLwL)0Vmm>H)9vt+S+uLIIUMv9gjy7ULH=naAVssgo_m~KSD<=v$a+pZ26|%_tFdz zDg^;;FAZsHxe)$66hQpx*^4>-IGpXOQF96{mHF8!pn5?-Jp^tMz}_%gYr{xt;!5E2ei2P_*5o%l7}{zuwV(Z28J_-Id0qHCw0KBL8qa}H zEa<$vrrR`|8yhzvn{wa-idF!Mf*eD@^G-dgx0#>Br|yVvVtD0NT70Kc=ZXlIsJ7xI zGgbMI)U@@1d0oqYDW`tH{?c9K_YdaaW&ow!0%FApdU_kd-fk^SLyGgau6c}bZDW2_ zRR;4;bw;?EfUnOHJoHpmYUIxQQw;F1RLV;16EOq~_nDP#_Zr7X3=EZ_WEe~~1Y==& zNh)OPcNMM-baWV5L`oIp&5^9w(Z!BGJj(FV@Vo)9I++unD|s}EaK!*fm1?CdEv7BH zMFIHWnErp$%PmC#gnz8=7HUqGn{Y?6(yu~y%AaB7u;|t-k|E!@A2a@t)YrzCTqS|3 zlvIAJGV~gizhr2tQ*_BDC0fXox0&O4$+`d#+Fq_6^cO+8GeyC~zkfEPN}s-oUXSI! zIGjD2VQ?Rkq>NCg8ppoQR!TSMTJk!>?No-4{q_klId*F z3-u4rZM=k$%jRNRJ{b>&cVMw7jQ3+ZFF6@}Ic|fHCxkNTkwNdW5tL(UC=gRIKzUSS zj+VBr4C@wEt&}pP^HN($lu2UEUufNDz0C{Iw0+yWAD@>5bV=H@UiIHOdoWxLS}UNt zcj~|V0(y6~;lTR+pN4gx7uBqr4Ab}3i8jElxW}9AQQ+Vked7|?UE2#)8DX(y^SZTe z^>le_>Lwub?ft>73q`aGi_{Aq3gbvJI6m38MewoSm-*#iWy< zs8mbG{-lmd^BWe+@6TuKSsi7S&RzMPnd zm97YH59KcS8PB*>O(iS|4Su;CsJt-*)idA2>WV%2IU?pBu~Tl4N59^qOV5;X^%QA= z77(BEiCRif&jtMQd)`v@H=}WpV@KTwnPj! zknat(d7;@c(T{1UikYbgy9ppY6u$|oA~qt4lbEF>o8pXrkGFZRrI{5GhSFi|(k_b?1aHe$|EKG#p0-I35}`9Iv**;V>vAHJ!m80E7l-7)RejJrYuqe z4&~z08}YhnSheH_CGrlH(`78W{R(o;cV{ zS~d*V0zfi_g$H~ytS;V$o49HRDh;}Fq7ioC1V6z+!d)@tK>x#S++gIu*e>3uy}@WN zV?Ie7*s^1Si=U^Ddf6@)pjUAf;uIJrdQP)NOH?>hC?u?9wyBg}d`ifuRYDH~q**hE zqz!?P)}77#hT+x7u$!z+F`M4lQ;w&;t1Jxdo{}%Jxmrx)hNqsoSZ`^%aME2}e3{HV zI9ctu8nwuTbwE}F|63OPkOg7=M_JG(;m5V`tI&?}r}*i$X$kBC{EQL*3YMzOIBhB5L5JW-H}_M}!gf zGWJHBLRmvas+AAN8GK$?ZNWJ*OXO6Q+3%-4N5SE7XARoX5P_)Z(3oL=LHKA!fbL@- zx06T7$+9%wPz?%-_Uf8GX9=~0@sGT-FE{6BVuX_m#nRQ2gB=V~x8MB&2qLD)CMTZY zZ63;DnXiqD)7%>@8B8EAOE48hleG}`2qIDpmY~kW+|ZG0yvd)gQ8Og*koVqv;`bWW z(1!OQpBmHU(<~~Fzkm;}e2#eGcaS|>gOTNLcG}Z8qy46>26Vx!zb+`dJP16Z{ZTh6 z84<(+R<+)1=WlX~${K0COUn;A012Upd14~t^0kRTGFod>WUN{(J4i}Q(n72>p6)P= z=XKpsCUJIKBPHCb+Qn&juePw}uCheMBM-&a!GfS`q^L5Td!un(QH_mxh7-TzzV|x* z*p}JtW`plJwYL6(o<8{j$mqX#-SzHJ?9dRPXXm;4zJ!8`tVRur=1oPfv_}p3akeI) zrw=2yu;?7(otmmf>9gn8TJ!s%EaK3rW|M5M9YcoS-}OHCS)q}9u)K7f&86GbrT9V$ z4sxpLFr9+$HEcF@?mOCBdI5(=o4uRAKOOev3JSasrf*!8V*T6wK*d5IcMJRDJ%7tX z2Vb+IY0h;y;Bw=wAu8E-yZylDw1VH=uB9vGoX%}dSqu+82h{%?F)-cv_J}SA_K&n* z1pJsAuq3W=!h!Y-gO?EbU7&4$(e?hGbw>fZIv-jm7o@F#QUjU2wLb^lD8|QNqoL5f zT3)BdW}I7-4uk9tJt9(FT)wC@4s8xV_{Q+OPzgzZ7;@pJbrt|J_qut$35R#z^~-Tk zw8MSiN%egWtbw;m*%^?!HvwdnJ7|0zR!RZ#-v3VBw!JWm!9QMAdJNE2!itls{ysl0 zu#Kb2dYV}LxmcIy>z@=d8fo@_v?k)nanI#8n%0ivqSB&t8^F_6qK|R4HI@dw!Y9a% z>s;=B^Lfl)<^Q@N!IB*%y-f9ZvYL2J9Y_M$zez(*bXffo;xI^>HnUnJWll;TpOBN_NZ49y+)YoxR^BMiAk`7 zwI1P6DPllj^WF_kkLkJ=9`hg}qgN0!;(=md^;n^j(0;s|KrG?_T@|>Ko?Yy7Wiv!D z#N*OkKC2RR!NQA*ZMdw`q| z6y9XxlbJG|UDd}i)~IYC2HbmQW?*_=XJ6=MmN5 z-t%YaJjF0HZd(@Gr+51w`>{Yv2?6~xelp-6)MMau62|Ui(PPY`PF)ezhE=sb-FeQR z^icPgG%}`;(zke6hi`m_fudoG(`kaflSDQnr7;ogIFF8$L~2N};{R76GKy7lWXxur3L z2wC25%L><3{q1fbzq={huT0iRmxkJ zeW^4jWdA`f7H(0`9#U-#q3f3<(tzcp5xABq-PR`1{R8c~KjU*KTq=;MQi~(?R|g?y%B!D=Nm$Wq7&)T<4HC7-Ig9y+Z3J_%YtZs@0fgk2BTc zY$5@J5DoLeA734H3N&vWuyhD^cB8PcKsG{H6G6g))S9-22KLouYsJq^3+87Cb-cFz z)O_k#e`<^r0t39)W+{d(Dr!~T!(eg`{@T2P7YLF`Y)w*A2I}USEFVbM4@gu!KUfFl z!toHkX3tjl-b)m#QmVqvCNEi}0=7a<(ePJ4?*DC0Q%9wT_l?$}DAZPqtra z>2G~|_I&!;+foyHu;t^Y*HvM&Xc52MF_COTh+T!XG6XYB{{2?KQ`d0TKIeYFY3wdh zr?i$__9=OR&W?JmpWI~sh0o#cs;+T$8`FY5d?q9Fhd-A2lar^QucxQuXY=mC70ZB< zS&9Y-6sMK5Z13eT&jzT6^V^HHbEM#MmJtGv?a8CPaaAJi=l+?}uWCOPN*`I(r!huO{JOMzk3dSDYNHWFYmyuai~oe^o)bT^fZ&{UqSDENX0k1NN@x|!a0usG|-1n+Wpj<*b2lvN`X$lR<0x12#&~m@= zog1nSTZ$D66nC0yy2nE`&VHgib(C$T{DM~Zyx=yvIkuRWYrwKe`P0}!JYClZAv?89d(FA3mDQct*f6U^rxWz5)6e&fcb}TSM)!oBz8g2b&YA%}|U8(JC~s#h$| z>JP=)acRnM9+%Cu9%n>mRumtn(&vzA@Aw?zzD$OooFTBW9mPu0kpJd7zf~up|F(Wi zQnG86k6ZoQ{x5^!@7z}^p3>kS&BI?3e#_16iB{OKX9j2o1I|bKvz6cPB96_VL{v#-=(Q*)LY^euk%F#uwcl65cg2SO#NB{T=YL^K;udOGp zyqhS^H6bE8F_!)$C;0jsVl3PhEu6S|oZq9pkHF>G-6!-cOS(W%+J-U1>NB_=-_^mCJqYNx^$xnGK(HCS+~RfJH)}Bjs3?N3IPG=f@n97WC4V< z#rEjvp{D*{GE&j*&35r1P@{pt>ORl}^{dRg2KovGC=oX}+*XrmUFU2aqr~MnRjby! z2~T0PrdwrHgqUPgJOFt{f|p_yB<^Vo!G^i>KQ3$y>}<Q2?0 zR>h?dZS&{*<4-qW01>IuK*snx|0*6dgs(PC+Jf3r(SHp*{cGTTWGu)(3{(frRn&3> ztdh*r+uu!$m#)Pn&#+TD1na9=$g~uTn+kCS!}eH%cJ(59z_SbskZDVOC;i-|#I|wr z&_K9kbvBhI$lfxMlb5Lzy8mT$5;Vq_{J_MwLLER(xiTCdy4Pa;&LRT4``I~%csR;>A3>UOK(ulc703+nr$M&U z6;KQfM*cVgK7l5G@mH{A|GH%9*ZU20y4G@t_oj4;jf;);b6hh%TfrVkd3^eNvq`6M zs+?~cQDkjk3!4Alv4#BJsF_el1ConVcGo`HE#fX6+b`C@DqKMpcUFNv4xQ2WRI$wE z-_u+wpxH-xzxvSUe^G*;YpI*HaMl2lG{-ZG#AsvXnIcC68Q&8klE}`j2H`s4f#i<+ zzqPGr|KLZ3E$|tVDg6~Z8&QOc2hL~C z4d`>^7dPKdl55Z%Hn5FbXL0kR1Dc#u_Ml+(Ra~6ruEroR3{3t1vYA)^64`8uV}TN_ z9}#rvX+UR92$!HR!H&6;5k^pvV(R;Rb%+MmsbObw5-6Y#<#eK;t8Y~T9fpq6DiP!; z+k=QbG)(I2Q?U=%AbW01C+;S!|3GrTN(4HU3jR>PO~5Ak7S3*<1f6#dk^3W55~4us zxE?4(;A?8LdyN|`_zc`iT zxXJ3b$WX!gxu}SQefj8$jn>HZdRiPl1;!$PEtE!MKHbHUJ%QXLc~`#x$~@lE+e*B< zYUsNRuL!hF-abo$uSJJ!MrfEg;T7=(8S&#g4k0EyH1YZ9DO!e#39sPjRr|(v=ym&XgSPNj4u6%A^Uu$bAGHo{f$}&AFP=J~< z^PNm!-^gFt<$Fe6Mjq;csQ4*!d){sUTxV|xs*4wjlmexH+-J(5cv$vcrLJ!Z6+eUp zg6x#?tbSApq@h5Eesa&_yd8CVxOI8}esyiujfc#|7WYM)FHuX!gZq5#yl?rz17fq< z#DmYqR$EKuTI=m(TP@OQHmB!Rq!-fUTRuyL;ziH(CnEL}1IK}^cBCkXrKSwD!LXoL z1AiVhyOaA&ZdV@Alf)Jc-x%TKt+vr2Kls{`ow!F+IIb)vaN;U)P<(8wIJPF@Pax0& zpFolQK7s$=KRd-Ke!9lInO-{XY|f9EIBcDQ|0?AXW=>}Ge$FHbIBE;;LM-)mk^pf}-s zGImkauixG6wNly5Zhe*U663=$a(Z8XOJA_f+Ti!S>S{5iKh0iRVVA|jUt`hTAfLd0 zRFy#e5}~vAU4W2gF}?`)epJ4ztDL*RMiSwV=!;?T9|KjF!C0v#?fK8bp*UI+GCioN zL}<_gdKlmwjA{_yfZ4@=F779A3(!9|I_Xe!V08Pm<6fJ*t)p^)K0=SGkWzKH_kc0o zHcpMTgTu^>eL+!B{ggB)vqVN7)OB(0vZLSZ`cn9Oz zEpA8K>k#hCdK-0j6Km|cY2*WtfL&q=!p%((xG#ei!+Z#ot&s5_ko z=;NS3A20hviu#fHpFrEiwETYV-_`9`#h@L8z1hr+jAu%p%Dr}az@kBNd? z00k`usfXiQUjgA^)m-t8v1d)P59vQ29jY5_*Ek(d1Yq0jj=J65Hw9!6ceni79Pd8~ zcx5KHMhMNKyH^j+s^`6Bxo2&jueG?I6A}^vcL6!E5gScY(nhBCYdC<}OtVJ_t~UG} z@-tB|vR~&hjGbPUMaIiFx7>+W#;jQg6(gp*-$V}sun z@Vf5)0tXIo1!{A?oq>cXjf3qzAdNCmZSXhyUaW7uwYUP$$#q<~Cs09DL;`9dTx9Rn zf2iq|ZNM*kR>7@NP%%zXaj^j5Lq!A_s2KhtkRMqtkaNC+M>!2gbb-X}wrxs;%D8}9 z#CAJQqG7KgR$i)q$w+CdQo3&$c8UcyC+T8XSh1@U)l1Bv>y_3Rt3AYTb-^1k0GHRI z%wn6m2WZRZzQ*foKpG3DPWi@Q^d+dFtod6A6LpYfWR=W(8UtsD&{u42{im#L4F*xp zLAe^^zJtf4Bz(8=;(I+Vpmp$Ayu@g}c~5LeC&4doD@| zou^;ciWb{hq_2a&ohU`+U5xo>8MuZA1v}OjkiB_bGrLJUjtNm?QArIVzEF= zDLVbTEZp-TKSDVfDv*WFihIJ9rMYm{#p1=i1-|JqTW{`m7n%XKWR&>en^><((Ycg+ zR>HH?cetl9)|&(j7IrNDx{7^ix+ZlsP6dx4ah0~3#o@5}MfQe8tp^QtZyGxZL*DQZ zMkckn+@R$yy?T`Mv25_g5Xe`KvO*1GqbR=Gw%ja5YLkY_hnVr#{<#$*-=}*jo%Xkv z%9TpbWAxXok(YNsI?*H2#%rgp$14cu**1j#jwH}`cRVkO@jr6jQ)vG@*0PecDV}=L zed)B}+}z3pN`$pk|Cb?8hh=Dub{5BXlbRHrsNIrNCbylDhuMFf0&0yVUZtE$it~9FNcr3=0 zd*}DD=8FkQ_bg|?g{E^7gm%%4M(*lb!ch*2^fGcg;@-=H@gw=nYd@ho9$+rK5o<*F zOXjye6m%@yR%h9N8_N)JLsA%LF$+>|DuLi;lC(cDF~FEkXZn@gG~zR+6TY~MU0-kh z+0eaYQ#ku|Sd7opdE4BdUO|XluKSzF3Xbq*Bl2eFX5%W&G4NW)|BMsv?*d!{?MSQC z6&(s(13iJr1{L~RT*)`Vy3iBJw@ZH3?(C~dB5Kyj+p2HBtZLkGnjT}(#N#<+DGyH9 zXKJ|Tem1f80a~V%#RyPx?i?bRoYDV~p3^l9jjC8w4ogZlmiLO-Z<5}L#&>2AasA;^ z1_9o5P4*PC?faO2LIk*#gu}-;ye8KY5uxQ)n<9)PsVW5?52jn0oU541mDQ_Eo^p4j z*k2_aO}=`LuGQ=zNL3K5zgu>^P5VCdSzddKjC6>I6o|+fC94X9fZ~w>W4B)}9{MAj zGsr=EwC8k+d{P7o&6fxA>2~Fz;u*Qjz4_NAH;RZ>d-F6_*VA;j*}Rf9RrYKTUn0r^ zvFxYlDQQ#vS;Vg`=v-#*Fv&@!y{(XkoZdu!ivo3EFB2?RNO-yQ@OdhfB4y3yduQCp zBO)clYFk#x95*_5@7wayuSpd4))+Ye?Ju>3)KOekK7LD#1t!`Vq(wBSRNp zdR2XK)^3frA{EQmZqQt$coYZloegbD$7jq0)3kbGL2XUQQ6i%*LJNnr-F~ z=8T-uSJ&Q2!yT+4*>B_dY$+uJK+dUZ?%dtGkn53rDs!cNd%bu&o!js@(<3k!QR(Gm zR)f;~x*!My3TL@*Xdf<{8|WLN?UnsC4!MLh3(~un;nMHlHh?OxP?zCzZPL+@bw#~r zw-)R(a@N(h{}>YIwYoxQm2 zHcTAa3kDCPfU3`_lUWFChem}W+Uq6vXhK7m(wJ=4bv6pvRf5sZWMg4Va@ zXA(P4Ts9@(5R20L{(W^l)s?62{dVu|L1*3fdANP{X*H_H)E1xT;d$%*rGJg4BmPSm zAK*+j(>a5!Rd!lXj~SR+Q|5Q0eB?(0If8mqh{cvO?BJ5|a zXaSd20@EzoR_O!oxXSi%XuuhEqpZt}h~uaSN#I6->QsHxg~@iVSO+lB8bOzWbB`qw<+i4>tzKi_t?RcPwhXxqeHQ8Qw30M4pc_z-=bKuDPGhT|Y5ND&e6Xy(u}OBk>`iZ*ZAR711f4 zQQM)1m)Lwe!~|rmIUnOZP~lCWn34c`pN`!>0(o#z_ey;jLP>Pl8r!Hl3d!ETeh~#<^^Fi@1#wNduJcUbAQqHH%5!0*Yk$b=gC7AeodQTjKHgiiALR)DlgMAb{* zJq(btE3=s`91{G?cpNLyJ>OJy_OqilA>0vC%b4vr$(9CPP;+n`LVvUc!?4td?{ z{8sq`v|R-nWT9wwc@5zo0OupV9#P|eUYl6U6dqeK!;-n9E4;e;X$@Yg`m~Q7x~(&d zD%H<9ci%7Tr55H;^Xg{By;j$A&fIeAVyto`Vrj|Xtb7ITGb*jODXAd6{O?W)64aFE zSPoW->fkt!ZqR#}<?jc+zT8RfkBUMW9C3;}XfiFB-e0-`V6=8@5I1dK@%>ou5HzHn!UPT00CCE~k3@K>{{g{ZD5f9Jsz@nkN4P30k4buNbeWrZ4ld^J+hswZED= zgyj>)Br}qK()8k`Gk4)&kGLFS9ARAjjP+V3HpwN+2)Yqlj7k_57n0V$uL-^6dRoQ> z2EvkXy7^b12!Tc(9pd$)SUU>rKGR zU5Lqo(`{baS%*9SLP~Ty%PIP!2H@xCe`AaL?EXAU--10n;)Ot&I{Hq08I2_V1~k7SxtU zed}WGle|2kpGE|CV7WDRX#fzftEV z@fJz#dI&G^f58fk{IK*HQ{gV^H&+6x-r1?B%CSI^IrR^U(1H4}#Vq_1sK4VnQvD8u zt=krWSfZ*p4w7`7U$*Yl7%l+5@rw+_tC$TXMexh8eNMdQ_qQAhr-NiRaN;CKbzine zrQUC^0TBZp?5(y8n#Xo|yc1rAAES9$zK!Ahno$?G)vsCnWc(#V_G{}%`(N3=SO_?w zJY46avV%tpd+&sT?8l874DcvVA9q?m{jfRodsa-B`cg`wS!ZlU*RG-5UDlf7V5*NS zLk>g&e72a6`kAyVR;w(z^s>5|16rBgp8^8i{;sz@zuEAgMWV328JyE=hoKowF6w_` zBHz5!CsvG@wWqsoKdOPlDpjXOg%^Ka+l>;khMq~sx=WDk$5o%wy?g|Tkynmy@bTZ_$Wzt*7tP3)JS{_V0THJQ``Fd zGE<@>wbD_)_pxt{wXuM&ju;k+uSvT#pXnahFkglrwG6O zY;eE^#6Z&=X&7Ps5|2 z#c*_Oke0^mO?{aZ*9x@{QP|i8B|m*Y)d-jfmN^J<5>6TM>m6#rt&e#pn5}lDnIxZ+ z`g$KMg;c`Ed-vEi?XD*wp6RD%YKtwth|q=XBZCG$x3RT@A0eJwr1$1bpmfWUn?*}# zJoo#<*9*!zA9K>b{47MC;RFi5ssi$vOe(f5#Inq64w-ZsY7zv((1Q2bpoCNacva$DQY=fD`x#MHuS*XE2F*JP@9V2+^LKaN-qBE1dCZvZo&k0-S+z z`?PYiPLfp%X`4^t45r(!t~p2!MlFm=iD{x~>(RV+#0=#h&V+iY?ivKI)>!AEqEkYA zpD?6M2-P|~fequQF0+#^KW)fH=k>A)9a8FcS10Wa+pO_QMy72dBijBD+4S| zKcL)8J{-7_%#&0aytNXrIS(Kf6cQAf?(@FxbUeHDY*v~}@j@p12e`rK6?^E%>^g}~ zOLx0k0i~wYmfem>jF@*OOVzG_-O&F;o0)K6AElEHh+8rbn5gSDxQvQ6zn^wg$S zYcixRO+|KR^PJ!@MPW`& zO_?rtzcT+xi1!88Rtf*t=LQ^igxXRfEt}mkM1&UW9%(j>lcLek$H80gC(p?A8gYy8 zwMI8k&R82@&&x~9lHc+(hkZiSZgXzbkF@V2aA2Q61{V+Y-D#zeK4w$k7G zqGg0?5ncK{e>?Q?TAM{(DwYU^+KgKh1z&emMW$L7MjJeJ1rc{0K{3;GT(CG2zOsB7 zwphZ_dO8)@@uUAXs*_&^1ID`>*zaNoDBHJ_Gmykp@_eQCm8F|Rf9M7qR2P}-v0kj4 zUsR)$CD?%;0~w#>*3R!AWZPdKqscsI!6Jh&JT*p z^c+!Y&X9piHy@eVja~B0W7Df+?(zJog{rel&k@p13d2D3ngvBXMwdGvz=~hbN zyb57Yw{_|z-l*yQ>k`m*D1(#V&gP64<>cS`oh{*NJBNdJuZ;0)Z$}HYH`By*mp5ot ztm9Vjv0=(*qX`P?3AO<$NF4H)DfRz-os4DSE+zl-D6#>3Io4?}#c+m%pPt57o~@B- z!(;2Bm%QqK1;d@a8;TsYU(d&56Ju4956IW_NJq7$P z8E4{38mOuix<78;CDx$*ykntuIfEgxZf%kO`D@HHIU0kOahKik5g8K&Ka*XS|m0up*hOI+ymeZ zKNFl9`$NRf*rZXo|LP40@Ro&{c9JLHUWe*bhDDbC{p7M*~WmQE!mc*lRCjmsXnU z$Y`xHblDt*9|lb%)p*5t2*X%b>Ggb>+qLZt1m7hV_#WIkQ+bQ&82p2fYx)3qy7`yU zA2K;SPyaAgxueH6K;#Y=Vqg07b(=E?`!(*?9*{crcpvj^m-CARFZ+yTY?*fVpc#hd zO|gJtPM67-oB#5ISzy2hMScU&ur^Q>kUfqQDu1kXZ)tgJ

LGx?u73U-E38A02Wz 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

HA2j|wGDGUv~`S4(W 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%0D3H+6LlruZo}Ioj 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·Ik{#@WG-d5Z5VF+8l(Yohpxl53Jg!v;$d2se^ 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 DwSSMb literal 0 HcmV?d00001 diff --git a/docs/images/ipad-sign-in.png b/docs/images/ipad-sign-in.png new file mode 100644 index 0000000000000000000000000000000000000000..7220f46d94e46a273614580da23036572fdca592 GIT binary patch literal 276122 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

?oRtC;jQs6 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

pQw5qRov8W^~LJDhF> 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 literal 0 HcmV?d00001 diff --git a/docs/images/spaceworks-icon.png b/docs/images/spaceworks-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6ab49823fb65c2feae8bca296abaa5ec30aa7eb3 GIT binary patch literal 32572 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

oIRkq-#!d23#+gk?g%Uqwn&BN7e_+ zhluJPzYxd$n?KBK35DW~9it7pH7RQ5KQ|E^*t+k0j+{b}HWd0gP+%iCW3j{Lizh)()hGpZ)H6pI`<^>b-YAukd~KfdRt5w}dq~rQ$br zxRn+ts0$T1FAhWhkeQmE6G#mU`Zu|g(KcVb!+}(&xk9d8eDC)Lr0R$mQ;eU@*ZB+2 z5u1{#`Tn{)31=>(!^QPuaU;u$t^K~+EJZNB>`vh<2+6eXK|)pLSis6l4TBrgVTLPW z^td=sqD~1Hn@jv?`=4M{?QdD6k3W4B2lMF!JN5Rk(!;f{`EBxu8}W9kdQmU&))baRxdhJ&9H79AG-!^X6iN;+ zsuAUB3g;{AIg7qB5AqAiG-|h>I0AEf`eJVKyGO$UWuc~9NET-;V4tznGZ;OBH9asJ*enW zBf1CixxWZqx>Kh_gDzQo;Z#0>OgvxqcielvpT8PbBb85V;j~is`(#%EHj3y`%NV}- zoL$Qx(ok-}<~SmLpa~1}#%%LoD@kb&D-%K$4wc1kAuTci+X?C2wm_vYq$T?S?yGEF zF${QS!eFx!D`h!bnRVwgm9C+%Eq_7zWbsg0fnA8l?zXKg2vK`Bx2H3{pT--gB#VjH zQQ~}Sf7t9?p=Y4hnlZx{bN_0O~G-*Vc#u}w;95D z$Al5m)`PU>J8YEumci8}!b%g_>`aK8R&TNp2PVGg93KlZ_{BIN0l~zFE9S6g4Gah8$~z8=b~Z_yYmMq9ldw>uD^46f_k5YK3`xMb$B4r5rXL zSh`rwUYC3BLWai$@2YJGC4PJ1^l4YA)}R_uss>gr|6JM;%id>3R-)Fh!evIB2c=7@ z>@x|mkK?wGF6Yy5wtTh-sgteB^Dji#aeJvQ_q6-0e~8bQZ3y+^O3qosS4Y+?bmXmo z_0N3ne13IgZj)Q~=EsuP&2z+fnUMS$nV-WsO}Q01x7jFDsTE$zCKnY67ZpunZGUwl z8uBsZO3|>eHPhPKYiC>?R2)Kna#T(MpU;S!v2kTmF8{6)=6%ybu$G3nyiLq8P!~D@ QOBm5QrGK*EI5G790B{`DO8@`> literal 0 HcmV?d00001 diff --git a/docs/mobile-auth-ux-spec.md b/docs/mobile-auth-ux-spec.md index 85ee35f..3099a22 100644 --- a/docs/mobile-auth-ux-spec.md +++ b/docs/mobile-auth-ux-spec.md @@ -10,7 +10,7 @@ The original mobile PRD remains the scope owner: `SpaceWorks/docs/mobile/MOBILE- Contract reference: the apps' `api/openapi.json`, SHA-256 `d5ec904eb2a52ca948ee38dae7840c8859a43e034a22f3b8dc478fd8851cbb9b`. Paths below include `/api/v1`, as do the generated operations. Configure the server origin without duplicating that prefix. A different schema in a local backend worktree is not a replacement contract. -Brand inputs: `design/tokens/tokens.json` is authoritative for color; `tokens/mobile-motion.json` is the shared machine-readable mobile motion contract generated into each app; `design/docs/ui-brand-spec.md` supplies the logo, spacing, motion provenance and voice; `design/docs/ux-yuyu-spec.md` and `design/docs/ux-screens-longtail.md` supply task-based layouts and states; `design/brand/space-works-brand-guidelines-v1.pdf` and its supplied page exports support visual verification. System fonts remain the mobile default until an app-embedding license for Gilroy is confirmed. The supplied WOFF files do not establish that license. +Brand inputs: `design/tokens/tokens.json` is authoritative for color; `tokens/mobile-motion.json` is the shared machine-readable mobile motion contract generated into each app; `design/docs/ui-brand-spec.md` supplies the logo, spacing, motion provenance and voice; `design/docs/ux-yuyu-spec.md` and `design/docs/ux-screens-longtail.md` supply task-based layouts and states; `design/brand/space-works-brand-guidelines-v1.pdf` and its supplied page exports support visual verification. The owner's later full-brand instruction uses the supplied Gilroy faces for app content with native Dynamic Type/Bold Text support; platform bars and system-owned controls retain native typography. IDs and secrets use the app family with tabular digits. Font provenance and applicable licenses remain separate from implementation: supplied assets and this document grant no additional embedding rights. ## 2. Shared visual language diff --git a/scripts/check_build_tools.py b/scripts/check_build_tools.py new file mode 100644 index 0000000..f7bf627 --- /dev/null +++ b/scripts/check_build_tools.py @@ -0,0 +1,27 @@ +"""Record and verify the already tested generator versions; never install or upgrade tools.""" +import json +from pathlib import Path +import re +import subprocess + +ROOT = Path(__file__).resolve().parents[1] + + +def inspect(): + expected = json.loads((ROOT / 'Dependencies/build-tools.json').read_text(encoding='utf-8'))['versions'] + commands = {'xcodegen': ['xcodegen', '--version'], 'librsvg': ['rsvg-convert', '--version']} + observed = {} + for name, command in commands.items(): + output = subprocess.check_output(command, text=True).strip() + match = re.search(r'\b\d+\.\d+\.\d+\b', output) + observed[name] = {'output': output, 'version': match.group() if match else None} + report = {'kind': 'actual-build-generator-versions', 'expected': expected, 'observed': observed, + 'matches': all(observed[name]['version'] == version for name, version in expected.items())} + (ROOT / 'build-tools.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + if not report['matches']: + raise SystemExit('Generator version differs from the reviewed build input; review the toolchain instead of accepting an automatic upgrade.') + print('Reviewed generator versions verified.') + + +if __name__ == '__main__': + inspect() diff --git a/scripts/dependency_lock.py b/scripts/dependency_lock.py new file mode 100644 index 0000000..ebc851f --- /dev/null +++ b/scripts/dependency_lock.py @@ -0,0 +1,59 @@ +"""Stage and verify the reviewed Xcode dependency graph without resolving or updating it.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import shutil + +ROOT = Path(__file__).resolve().parents[1] +CANONICAL = ROOT / 'Dependencies/Package.resolved' +WORKSPACE = ROOT / 'SpaceWorks.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved' + + +def read_lock(path): + data = Path(path).read_bytes() + document = json.loads(data) + if document.get('version') != 3 or not re.fullmatch(r'[0-9a-f]{64}', document.get('originHash', '')): + raise ValueError('Expected the reviewed version-3 Xcode dependency lock') + pins = document.get('pins', []) + identities = [pin.get('identity') for pin in pins] + if not pins or any(not isinstance(identity, str) or not identity for identity in identities) or len(set(identities)) != len(identities): + raise ValueError('Dependency identities must be present and unique') + for pin in pins: + state = pin.get('state', {}) + if pin.get('kind') != 'remoteSourceControl' or not re.fullmatch(r'[0-9a-f]{40}', state.get('revision', '')) or not state.get('version'): + raise ValueError('Every dependency must retain an exact reviewed version and revision') + return data, document + + +def stage(source=CANONICAL, destination=WORKSPACE): + read_lock(source) # Refuse invalid input before touching the generated workspace. + destination = Path(destination) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + + +def check(source=CANONICAL, destination=WORKSPACE): + expected, document = read_lock(source) + actual, observed = read_lock(destination) + if actual != expected: + raise ValueError('Xcode dependency lock differs from the reviewed source input; review the change instead of resolving automatically') + return {'kind': 'reviewed-dependency-lock-audit', + 'canonical_sha256': hashlib.sha256(expected).hexdigest(), + 'workspace_sha256': hashlib.sha256(actual).hexdigest(), + 'pins': len(document['pins']), 'origin_hash': observed['originHash'], + 'byte_identical': True} + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('operation', choices=['stage', 'check']) + parser.add_argument('--output', type=Path, default=ROOT / 'dependency-lock-validation.json') + args = parser.parse_args() + if args.operation == 'stage': + stage() + else: + report = check() + args.output.write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + print('Reviewed dependency lock retained without changes.') diff --git a/scripts/tests/test_dependency_lock.py b/scripts/tests/test_dependency_lock.py new file mode 100644 index 0000000..7f8deb3 --- /dev/null +++ b/scripts/tests/test_dependency_lock.py @@ -0,0 +1,39 @@ +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location('dependency_lock', ROOT / 'scripts/dependency_lock.py') +lock = importlib.util.module_from_spec(spec) +spec.loader.exec_module(lock) + + +class DependencyLockTests(unittest.TestCase): + def testReviewedArtifactIsStagedByteForByte(self): + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / 'generated/workspace/Package.resolved' + lock.stage(ROOT / 'Dependencies/Package.resolved', target) + self.assertEqual(target.read_bytes(), (ROOT / 'Dependencies/Package.resolved').read_bytes()) + self.assertTrue(lock.check(ROOT / 'Dependencies/Package.resolved', target)['byte_identical']) + + def testChangedTransitiveRevisionIsRejected(self): + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / 'Package.resolved' + lock.stage(ROOT / 'Dependencies/Package.resolved', target) + changed = json.loads(target.read_text(encoding='utf-8')) + changed['pins'][0]['state']['revision'] = '0' * 40 + target.write_text(json.dumps(changed), encoding='utf-8') + with self.assertRaisesRegex(ValueError, 'differs from the reviewed'): + lock.check(ROOT / 'Dependencies/Package.resolved', target) + + def testMalformedSourceDoesNotReplaceWorkspace(self): + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / 'unreviewed.json' + source.write_text('{"version":3,"pins":[]}', encoding='utf-8') + target = Path(directory) / 'Package.resolved' + target.write_bytes(b'existing workspace input') + with self.assertRaises(ValueError): + lock.stage(source, target) + self.assertEqual(target.read_bytes(), b'existing workspace input') From 0151aee6380f131817166e128a987c9694590179 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:30:50 +0530 Subject: [PATCH 77/98] chore(c2): observe native export presentation failures Co-Authored-By: Codex --- .github/workflows/ios.yml | 55 +++++++++++++++++-- .../NativeOrientationDiagnostics.swift | 36 +++++++++++- SpaceWorksUITests/NativeTestLaunch.swift | 16 +++++- docs/CHANGELOG.md | 6 ++ 4 files changed, 104 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index c5946f6..f14a512 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -8,6 +8,10 @@ on: description: Read-only tablet landscape diagnostic (one existing UI journey; not final acceptance) type: boolean default: false + export_diagnostic: + description: Read-only large phone and tablet export diagnostic (one existing UI journey; not final acceptance) + type: boolean + default: false permissions: contents: read concurrency: @@ -19,18 +23,25 @@ jobs: env: DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer ORIENTATION_DIAGNOSTIC: ${{ github.event_name == 'workflow_dispatch' && inputs.orientation_diagnostic && 'true' || 'false' }} + EXPORT_DIAGNOSTIC: ${{ github.event_name == 'workflow_dispatch' && inputs.export_diagnostic && 'true' || 'false' }} timeout-minutes: 120 name: Build and test (${{ matrix.device }}) strategy: fail-fast: false max-parallel: 6 matrix: - device: ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.orientation_diagnostic && '["tablet-landscape"]' || '["compact","phone","large","tablet","tablet-landscape","large-tablet"]') || '["phone","tablet"]') }} + device: ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.export_diagnostic && '["large","tablet"]' || inputs.orientation_diagnostic && '["tablet-landscape"]' || '["compact","phone","large","tablet","tablet-landscape","large-tablet"]') || '["phone","tablet"]') }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4, verified upstream and actual focused47 job with: submodules: recursive persist-credentials: false + - name: Validate diagnostic selection + run: | + if [ "$ORIENTATION_DIAGNOSTIC" = true ] && [ "$EXPORT_DIAGNOSTIC" = true ]; then + echo "Select at most one explicit diagnostic purpose." + exit 1 + fi - name: Verify pinned Xcode and SDK run: | xcodebuild -version @@ -102,10 +113,12 @@ jobs: 'phone_orientations': required <= set(info.get('UISupportedInterfaceOrientations~iphone', info.get('UISupportedInterfaceOrientations', []))), 'tablet_orientations': required <= set(info.get('UISupportedInterfaceOrientations~ipad', []))} diagnostic = os.environ['ORIENTATION_DIAGNOSTIC'] == 'true' + export_diagnostic = os.environ['EXPORT_DIAGNOSTIC'] == 'true' + assert not (diagnostic and export_diagnostic) dependency_lock = json.loads(pathlib.Path('dependency-lock-validation.json').read_text(encoding='utf-8')) - report = {'dependency_lock': dependency_lock, 'kind': 'orientation-diagnostic-built-debug-metadata' if diagnostic else 'canonical-built-debug-metadata', 'source_sha': source, - 'purpose': 'read-only-orientation-diagnostic' if diagnostic else 'complete-native-validation', - 'ui_selection': 'SpaceWorksUITests/MainFlowTests/testRegularTextDarkLandscapeSignInUsesNativeBrandPanel' if diagnostic else 'SpaceWorksUITests', + report = {'dependency_lock': dependency_lock, 'kind': 'export-diagnostic-built-debug-metadata' if export_diagnostic else 'orientation-diagnostic-built-debug-metadata' if diagnostic else 'canonical-built-debug-metadata', 'source_sha': source, + 'purpose': 'read-only-native-export-diagnostic' if export_diagnostic else 'read-only-orientation-diagnostic' if diagnostic else 'complete-native-validation', + 'ui_selection': 'SpaceWorksUITests/AccountFlowTests/testLargestTextLandscapeExportCanReachNativeShare' if export_diagnostic else 'SpaceWorksUITests/MainFlowTests/testRegularTextDarkLandscapeSignInUsesNativeBrandPanel' if diagnostic else 'SpaceWorksUITests', 'profile': os.environ['DEVICE_PROFILE'], 'bundle_id': info.get('CFBundleIdentifier'), 'sdk': info.get('DTSDKName'), 'device_families': info.get('UIDeviceFamily'), 'fonts': info.get('UIAppFonts', []), 'scene_manifest': scene, @@ -122,7 +135,9 @@ jobs: run: | set -o pipefail UI_SELECTION=SpaceWorksUITests - if [ "$ORIENTATION_DIAGNOSTIC" = true ]; then + if [ "$EXPORT_DIAGNOSTIC" = true ]; then + UI_SELECTION=SpaceWorksUITests/AccountFlowTests/testLargestTextLandscapeExportCanReachNativeShare + elif [ "$ORIENTATION_DIAGNOSTIC" = true ]; then UI_SELECTION=SpaceWorksUITests/MainFlowTests/testRegularTextDarkLandscapeSignInUsesNativeBrandPanel fi xcodebuild test-without-building -project SpaceWorks.xcodeproj -scheme SpaceWorks -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -disableAutomaticPackageResolution -onlyUsePackageVersionsFromResolvedFile -resultBundlePath TestResults.xcresult -skipPackagePluginValidation "SPACEWORKS_TEST_PROFILE=$DEVICE_PROFILE" ONLY_ACTIVE_ARCH=YES CODE_SIGN_STYLE=Manual CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= "-only-testing:$UI_SELECTION" | tee -a build.log @@ -148,11 +163,38 @@ jobs: if [ -n "$SIMULATOR_ID" ]; then xcrun simctl spawn "$SIMULATOR_ID" log show --last 120m --style compact --info --predicate 'subsystem == "org.spaceworks.orientation-diagnostics" AND category == "orientation"' > orientation-system.log 2>&1 || true fi + - name: Collect bounded native presentation service metadata + if: always() && env.EXPORT_DIAGNOSTIC == 'true' + run: | + python3 - <<'PY' + import json, os, pathlib, re, subprocess + predicate = '(process == "SpaceWorks" OR process CONTAINS[c] "ShareSheet" OR process == "sharingd" OR process CONTAINS[c] "Activity" OR subsystem CONTAINS[c] "Share")' + report = {'kind': 'redacted-native-presentation-service-metadata', 'purpose': 'read-only-native-export-diagnostic', 'source_sha': os.environ['GITHUB_SHA'], 'profile': os.environ['DEVICE_PROFILE'], 'window_minutes': 20, 'raw_messages_retained': False, 'records': []} + try: + result = subprocess.run(['xcrun', 'simctl', 'spawn', os.environ['SIMULATOR_ID'], 'log', 'show', '--last', '20m', '--style', 'json', '--info', '--predicate', predicate], capture_output=True, text=True, timeout=60) + report['collection_exit_code'] = result.returncode + entries = json.loads(result.stdout) if result.returncode == 0 else [] + report['matched_events'] = len(entries) + for event in entries[-10000:]: + message = event.get('eventMessage', '') + classifications = [word for word in ['error', 'fault', 'crash', 'termination', 'dismiss', 'load', 'invalid', 'interrupt', 'connection', 'present', 'activity', 'extension'] if word in message.lower()] + if not classifications: + continue + # Export only fixed classifications/numeric error codes and log metadata. + # Never retain arbitrary app/system messages, archive paths or user text. + domains = sorted(set(re.findall(r'\b(?:NSCocoaErrorDomain|NSPOSIXErrorDomain|NSOSStatusErrorDomain|NSMachErrorDomain|RBSRequestErrorDomain|RBSAssertionErrorDomain)\b', message))) + codes = sorted(set(re.findall(r'\bCode\s*=\s*(-?\d+)\b', message))) + report['records'].append({'timestamp': event.get('timestamp'), 'process': pathlib.PurePosixPath(event.get('processImagePath', '')).name, 'subsystem': event.get('subsystem'), 'category': event.get('category'), 'message_type': event.get('messageType'), 'classifications': classifications, 'error_domains': domains, 'error_codes': codes}) + report['truncated_to_last_events'] = len(entries) > 10000 + except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError) as error: + report['collection_error_kind'] = type(error).__name__ + pathlib.Path('native-presentation-service-metadata.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + PY - name: Archive validation evidence if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: ${{ env.ORIENTATION_DIAGNOSTIC == 'true' && 'ios-orientation-diagnostic' || 'ios-validation' }}-${{ matrix.device }} + name: ${{ env.EXPORT_DIAGNOSTIC == 'true' && 'ios-export-diagnostic' || env.ORIENTATION_DIAGNOSTIC == 'true' && 'ios-orientation-diagnostic' || 'ios-validation' }}-${{ matrix.device }} path: | NativeTestResults.xcresult TestResults.xcresult @@ -166,6 +208,7 @@ jobs: build-tools.json Dependencies/Package.resolved orientation-system.log + native-presentation-service-metadata.json DerivedData/Build/Intermediates.noindex/BuildToolPluginIntermediates/**/GeneratedSources/*.swift SpaceWorks.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved retention-days: 7 diff --git a/SpaceWorks/Platform/NativeOrientationDiagnostics.swift b/SpaceWorks/Platform/NativeOrientationDiagnostics.swift index 29fa0c6..e1f7627 100644 --- a/SpaceWorks/Platform/NativeOrientationDiagnostics.swift +++ b/SpaceWorks/Platform/NativeOrientationDiagnostics.swift @@ -42,6 +42,8 @@ private final class OrientationObserverBag { private var readScheduled = false private var readRevision = 0 private var lastState: Data? + private var settledRevision = 0 + private var settledTasks: [Task] = [] private static let logger = Logger(subsystem: "org.spaceworks.orientation-diagnostics", category: "orientation") override func didMoveToWindow() { @@ -52,7 +54,13 @@ private final class OrientationObserverBag { UIScene.willDeactivateNotification] { let token = NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] notification in MainActor.assumeIsolated { - self?.read(reason: "notification:" + notification.name.rawValue) + guard let self else { return } + self.read(reason: "notification:" + notification.name.rawValue) + if notification.name == UIDevice.orientationDidChangeNotification { + self.scheduleSettledReads() + } else if notification.name == UIScene.willDeactivateNotification { + self.cancelSettledReads() + } } } observers.tokens.append(token) @@ -64,6 +72,7 @@ private final class OrientationObserverBag { scheduleRead(reason: "layout") } func stop() { + cancelSettledReads() readRevision += 1 readScheduled = false observers.cancel() @@ -81,6 +90,29 @@ private final class OrientationObserverBag { } } + private func cancelSettledReads() { + settledRevision += 1 + settledTasks.forEach { $0.cancel() } + settledTasks.removeAll() + } + private func scheduleSettledReads() { + cancelSettledReads() + guard enabled, let window else { return } + let revision = readRevision + let orientationRevision = settledRevision + for delay in [1, 3] { + settledTasks.append(Task { @MainActor [weak self, weak window] in + do { try await Task.sleep(nanoseconds: UInt64(delay) * 1_000_000_000) } + catch { return } + guard !Task.isCancelled, let self, self.enabled, + self.readRevision == revision, self.settledRevision == orientationRevision, + let window, self.window === window, + window.windowScene?.activationState == .foregroundActive else { return } + self.read(reason: "settled-orientation:\(delay)s") + }) + } + } + private func read(reason: String) { guard enabled, let window, let scene = window.windowScene else { return } // No view loading: .view is read only when UIKit says it is already loaded. @@ -138,7 +170,7 @@ private final class OrientationObserverBag { } guard let fingerprint = try? JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) else { return } // Keep actual notifications even if policy is unchanged; suppress layout-only duplicates. - if !reason.hasPrefix("notification:"), fingerprint == lastState { return } + if !reason.hasPrefix("notification:"), !reason.hasPrefix("settled-orientation:"), fingerprint == lastState { return } lastState = fingerprint record["event"] = reason record["uptime_seconds"] = ProcessInfo.processInfo.systemUptime diff --git a/SpaceWorksUITests/NativeTestLaunch.swift b/SpaceWorksUITests/NativeTestLaunch.swift index 86fe1f2..bf3cb21 100644 --- a/SpaceWorksUITests/NativeTestLaunch.swift +++ b/SpaceWorksUITests/NativeTestLaunch.swift @@ -18,10 +18,22 @@ extension XCUIApplication { if diagnostic { print("SpaceWorksOrientationTest after device=\(XCUIDevice.shared.orientation.rawValue) lastPredicateFrame=\(lastFrame) waitResult=\(result.rawValue)") } + if result != .completed { attachFailedOrientationEvidence() } XCTAssertEqual(result, .completed, "Requested landscape orientation must actually be visible.") } + private func attachFailedOrientationEvidence() { + XCTContext.runActivity(named: "Failed native orientation evidence") { activity in + let pixels = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + pixels.name = "Failed orientation full OS screen"; pixels.lifetime = .keepAlways + activity.add(pixels) + let hierarchy = XCTAttachment(string: debugDescription) + hierarchy.name = "Failed orientation app hierarchy"; hierarchy.lifetime = .keepAlways + activity.add(hierarchy) + } + } + func launchForDeviceProfile() { let profiles: Set = ["compact", "phone", "large", "tablet", "tablet-landscape", "large-tablet"] let profile = ProcessInfo.processInfo.environment["SPACEWORKS_TEST_PROFILE"] ?? "phone" @@ -40,7 +52,9 @@ extension XCUIApplication { let orientation = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in landscape ? self.frame.width > self.frame.height : self.frame.height > self.frame.width }, object: nil) - XCTAssertEqual(XCTWaiter.wait(for: [orientation], timeout: 10), .completed, "Requested launch orientation must actually be visible.") + let orientationResult = XCTWaiter.wait(for: [orientation], timeout: 10) + if orientationResult != .completed { attachFailedOrientationEvidence() } + XCTAssertEqual(orientationResult, .completed, "Requested launch orientation must actually be visible.") let appearance = buttons["appearance"] XCTAssertTrue(appearance.waitForExistence(timeout: 10)) XCTAssertEqual(appearance.label, dark ? "Light mode" : "Dark mode", "The app's explicit theme must match its matrix profile.") diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1a0be2f..febf7b6 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,11 @@ # Phase notes +## C2 — Native presentation diagnostic checkpoint (2026-10-18) + +The historical `775f298` six-profile run exposed two different export failures: the large-phone app-frame landscape predicate failed while its actual settled scene geometry remained unverified, while the tablet rotated successfully but the actual native Save to Files action remained absent through the existing 45-second expectation. The tablet's final native hierarchy contained an empty remote activity container; its recording covered only roughly three seconds after Share, so it does not establish a 45-second blank visual or an app/OS cause. + +Add an explicit diagnostic-only workflow option for those two profiles and the same existing export journey, mutually exclusive with the orientation-only option. Default push/PR two-profile and manual six-profile validation still run all 302 native and 37 UI cases. Failed rotation now attaches actual full-OS pixels and app accessibility hierarchy before the unchanged assertion. The double-gated Debug probe takes two cancellable read-only scene/controller samples one and three seconds after a device-orientation notification; a separate bounded diagnostic collector retains only service metadata, fixed failure classifications and numeric error codes, without raw log messages, file paths or app text. No orientation request, presentation wrapper change, extra input attempt or assertion/timeout change is included. Native compilation and the focused diagnostic remain pending; this checkpoint is not final acceptance. + ## C1 Reviewed build inputs and repository handoff — candidate checkpoint (2026-10-18) The owner requested production-level repositories. This candidate retains the actual 20-pin Xcode resolution from source `47e1880`, run `39100123222`, artifact `12125599794`; package declarations were unchanged at `775f298`. It introduces no package upgrade or new SDK. The generated workspace receives that exact reviewed lock, builds forbid automatic dependency changes, verification rejects rewritten bytes, and cache identity includes the lock. Existing GitHub action versions are pinned to their verified upstream commits already shown in that actual job, with checkout credential persistence disabled. XcodeGen 2.46.0 and librsvg 2.62.3 are recorded and checked against published installation evidence rather than silently accepting future generator upgrades. From ed2aca4b092fb9351b06670a691e909fcf51c02b Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:00:15 +0530 Subject: [PATCH 78/98] test(c3): guard finite native navigation readiness Co-Authored-By: Codex --- SpaceWorksUITests/AccountFlowTests.swift | 4 ++-- SpaceWorksUITests/GuideFlowTests.swift | 2 +- SpaceWorksUITests/MainFlowTests.swift | 2 +- SpaceWorksUITests/MemberActionsFlowTests.swift | 2 +- SpaceWorksUITests/MemberFlowTests.swift | 2 +- SpaceWorksUITests/NativeUIActions.swift | 16 ++++++++++++++++ SpaceWorksUITests/NotificationsFlowTests.swift | 2 +- SpaceWorksUITests/PaymentsFlowTests.swift | 2 +- SpaceWorksUITests/StaffFlowTests.swift | 2 +- docs/CHANGELOG.md | 4 ++++ 10 files changed, 29 insertions(+), 9 deletions(-) diff --git a/SpaceWorksUITests/AccountFlowTests.swift b/SpaceWorksUITests/AccountFlowTests.swift index 5e8e134..563170e 100644 --- a/SpaceWorksUITests/AccountFlowTests.swift +++ b/SpaceWorksUITests/AccountFlowTests.swift @@ -14,7 +14,7 @@ final class AccountFlowTests: XCTestCase { if app.tabBars.buttons["Profile"].exists { tap(app.tabBars.buttons["Profile"], in: app) } else { let profile = app.buttons["nav-Profile"] - let readiness = NSPredicate(format: "exists == true AND isEnabled == true AND isHittable == true") + let readiness = NativeUIActions.readiness(in: app) let ready = XCTNSPredicateExpectation(predicate: readiness, object: profile) if XCTWaiter.wait(for: [ready], timeout: 3) != .completed { let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label ==[c] %@ OR label ==[c] %@", "Show Sidebar", "Toggle Sidebar")).firstMatch @@ -25,7 +25,7 @@ final class AccountFlowTests: XCTestCase { let openedResult = XCTWaiter.wait(for: [opened], timeout: 10) if openedResult != .completed { let exists = profile.exists - print("Profile after native sidebar: exists=\(exists), enabled=\(exists && profile.isEnabled), hittable=\(exists && profile.isHittable), frame=\(exists ? String(describing: profile.frame) : "unavailable")") + print("Profile after native sidebar: exists=\(exists), enabled=\(exists && profile.isEnabled), ready=\(readiness.evaluate(with: profile)), frame=\(exists ? String(describing: profile.frame) : "unavailable")") } XCTAssertEqual(openedResult, .completed) } diff --git a/SpaceWorksUITests/GuideFlowTests.swift b/SpaceWorksUITests/GuideFlowTests.swift index 8b5d7a1..f8b7606 100644 --- a/SpaceWorksUITests/GuideFlowTests.swift +++ b/SpaceWorksUITests/GuideFlowTests.swift @@ -17,7 +17,7 @@ final class GuideFlowTests: XCTestCase { private func tab(_ name: String, app: XCUIApplication) { if app.tabBars.buttons[name].exists { tap(app.tabBars.buttons[name], in: app); return } let target = app.buttons["nav-\(name)"] - let readiness = NSPredicate(format: "exists == true AND isEnabled == true AND isHittable == true") + let readiness = NativeUIActions.readiness(in: app) let ready = XCTNSPredicateExpectation(predicate: readiness, object: target) if XCTWaiter.wait(for: [ready], timeout: 3) != .completed { let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label ==[c] %@ OR label ==[c] %@", "Show Sidebar", "Toggle Sidebar")).firstMatch diff --git a/SpaceWorksUITests/MainFlowTests.swift b/SpaceWorksUITests/MainFlowTests.swift index f89e5b5..8553589 100644 --- a/SpaceWorksUITests/MainFlowTests.swift +++ b/SpaceWorksUITests/MainFlowTests.swift @@ -28,7 +28,7 @@ final class MainFlowTests: XCTestCase { private func tab(_ app: XCUIApplication, _ title: String) { if app.tabBars.buttons[title].exists { app.tabBars.buttons[title].tap(); return } let item = app.buttons["nav-\(title)"] - let readiness = NSPredicate(format: "exists == true AND isEnabled == true AND isHittable == true") + let readiness = NativeUIActions.readiness(in: app) let ready = XCTNSPredicateExpectation(predicate: readiness, object: item) if XCTWaiter.wait(for: [ready], timeout: 3) != .completed { let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label ==[c] %@ OR label ==[c] %@", "Show Sidebar", "Toggle Sidebar")).firstMatch diff --git a/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift index 61b5467..2f792a5 100644 --- a/SpaceWorksUITests/MemberActionsFlowTests.swift +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -219,7 +219,7 @@ final class MemberActionsFlowTests: XCTestCase { private func tab(_ app: XCUIApplication, _ name: String) { if app.tabBars.buttons[name].exists { app.tabBars.buttons[name].tap(); return } let item = app.buttons["nav-\(name)"] - let readiness = NSPredicate(format: "exists == true AND isEnabled == true AND isHittable == true") + let readiness = NativeUIActions.readiness(in: app) let ready = XCTNSPredicateExpectation(predicate: readiness, object: item) if XCTWaiter.wait(for: [ready], timeout: 3) != .completed { let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label ==[c] %@ OR label ==[c] %@", "Show Sidebar", "Toggle Sidebar")).firstMatch diff --git a/SpaceWorksUITests/MemberFlowTests.swift b/SpaceWorksUITests/MemberFlowTests.swift index 2ddaa8a..1d4e548 100644 --- a/SpaceWorksUITests/MemberFlowTests.swift +++ b/SpaceWorksUITests/MemberFlowTests.swift @@ -18,7 +18,7 @@ final class MemberFlowTests: XCTestCase { private func tab(_ app: XCUIApplication, _ name: String) { if app.tabBars.buttons[name].exists { app.tabBars.buttons[name].tap(); return } let item = app.buttons["nav-\(name)"] - let readiness = NSPredicate(format: "exists == true AND isEnabled == true AND isHittable == true") + let readiness = NativeUIActions.readiness(in: app) let ready = XCTNSPredicateExpectation(predicate: readiness, object: item) if XCTWaiter.wait(for: [ready], timeout: 3) != .completed { let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label ==[c] %@ OR label ==[c] %@", "Show Sidebar", "Toggle Sidebar")).firstMatch diff --git a/SpaceWorksUITests/NativeUIActions.swift b/SpaceWorksUITests/NativeUIActions.swift index b42fde2..db613b0 100644 --- a/SpaceWorksUITests/NativeUIActions.swift +++ b/SpaceWorksUITests/NativeUIActions.swift @@ -2,6 +2,22 @@ import XCTest /// Native bars can overlap the accessibility scroll frame. Keep gestures in visible content. enum NativeUIActions { + /// XCTest can transiently expose an infinite/empty native window during transitions. + /// Reject that snapshot before asking its descendant for a native activation point. + static func readiness(in app: XCUIApplication) -> NSPredicate { + NSPredicate { object, _ in + guard let element = object as? XCUIElement, app.exists else { return false } + let applicationFrame = app.frame + guard [applicationFrame.minX, applicationFrame.minY, applicationFrame.width, applicationFrame.height].allSatisfy(\.isFinite), + applicationFrame.width > 0, applicationFrame.height > 0, + element.exists else { return false } + let targetFrame = element.frame + guard [targetFrame.minX, targetFrame.minY, targetFrame.width, targetFrame.height].allSatisfy(\.isFinite), + targetFrame.width > 0, targetFrame.height > 0 else { return false } + return element.isEnabled && element.isHittable + } + } + static func tap(_ element: XCUIElement, in app: XCUIApplication, requireWholeFrame: Bool = false) { var reviewDown = false for attempt in 0..<24 { diff --git a/SpaceWorksUITests/NotificationsFlowTests.swift b/SpaceWorksUITests/NotificationsFlowTests.swift index 78603ad..3ff87ab 100644 --- a/SpaceWorksUITests/NotificationsFlowTests.swift +++ b/SpaceWorksUITests/NotificationsFlowTests.swift @@ -6,7 +6,7 @@ final class NotificationsFlowTests: XCTestCase { private func tab(_ app: XCUIApplication, _ name: String) { if app.tabBars.buttons[name].exists { tap(app.tabBars.buttons[name], app); return } let item = app.buttons["nav-\(name)"] - let readiness = NSPredicate(format: "exists == true AND isEnabled == true AND isHittable == true") + let readiness = NativeUIActions.readiness(in: app) let ready = XCTNSPredicateExpectation(predicate: readiness, object: item) if XCTWaiter.wait(for: [ready], timeout: 3) != .completed { let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label ==[c] %@ OR label ==[c] %@", "Show Sidebar", "Toggle Sidebar")).firstMatch diff --git a/SpaceWorksUITests/PaymentsFlowTests.swift b/SpaceWorksUITests/PaymentsFlowTests.swift index b92112a..229d2a7 100644 --- a/SpaceWorksUITests/PaymentsFlowTests.swift +++ b/SpaceWorksUITests/PaymentsFlowTests.swift @@ -13,7 +13,7 @@ final class PaymentsFlowTests: XCTestCase { if app.tabBars.buttons["Profile"].exists { tap(app.tabBars.buttons["Profile"], in: app) } else { let profile = app.buttons["nav-Profile"] - let readiness = NSPredicate(format: "exists == true AND isEnabled == true AND isHittable == true") + let readiness = NativeUIActions.readiness(in: app) let ready = XCTNSPredicateExpectation(predicate: readiness, object: profile) if XCTWaiter.wait(for: [ready], timeout: 3) != .completed { let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label ==[c] %@ OR label ==[c] %@", "Show Sidebar", "Toggle Sidebar")).firstMatch diff --git a/SpaceWorksUITests/StaffFlowTests.swift b/SpaceWorksUITests/StaffFlowTests.swift index 4db7b9d..ab09a45 100644 --- a/SpaceWorksUITests/StaffFlowTests.swift +++ b/SpaceWorksUITests/StaffFlowTests.swift @@ -15,7 +15,7 @@ final class StaffFlowTests: XCTestCase { if app.tabBars.buttons["Profile"].exists { tap(app.tabBars.buttons["Profile"], in: app) } else { let profile = app.buttons["nav-Profile"] - let readiness = NSPredicate(format: "exists == true AND isEnabled == true AND isHittable == true") + let readiness = NativeUIActions.readiness(in: app) let ready = XCTNSPredicateExpectation(predicate: readiness, object: profile) if XCTWaiter.wait(for: [ready], timeout: 3) != .completed { let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label ==[c] %@ OR label ==[c] %@", "Show Sidebar", "Toggle Sidebar")).firstMatch diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index febf7b6..1f9d9c9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,9 @@ # Phase notes +## C3 — Guard native navigation readiness geometry (2026-10-18) + +In the historical `775f298` tablet-landscape Guide journey, XCTest exposed infinite origins and zero sizes for the native window and Activity target while evaluating a raw `isHittable` predicate. Actual full-OS frames showed the sidebar and Activity row; no persistent missing content or product navigation cause was established. The shared test readiness predicate now requires finite, positive app and target frames before querying native enabled/hittable state. Apply that same predicate to the eight existing sidebar helpers and its failed-readiness diagnostic. Every existing three/ten-second wait, single native sidebar/item activation, content gesture bound and guide/account/member/staff/payment outcome remains unchanged. No app code, timeout extension, synthetic navigation or repeated input is added; fresh native proof remains required. + ## C2 — Native presentation diagnostic checkpoint (2026-10-18) The historical `775f298` six-profile run exposed two different export failures: the large-phone app-frame landscape predicate failed while its actual settled scene geometry remained unverified, while the tablet rotated successfully but the actual native Save to Files action remained absent through the existing 45-second expectation. The tablet's final native hierarchy contained an empty remote activity container; its recording covered only roughly three seconds after Share, so it does not establish a 45-second blank visual or an app/OS cause. From 4a0cbf7e7c2e2a294e9425511716c1465b4ef199 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:10:43 +0530 Subject: [PATCH 79/98] chore(c4): trace isolated demo entry diagnostics Co-Authored-By: Codex --- .github/workflows/ios.yml | 59 +++++++++++++++++++----- SpaceWorks/DemoMode.swift | 9 +++- SpaceWorks/RootView.swift | 46 +++++++++++++++++- SpaceWorksUITests/AccountFlowTests.swift | 22 ++++++++- docs/CHANGELOG.md | 10 ++++ project.yml | 2 + 6 files changed, 131 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index f14a512..4438538 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -12,6 +12,10 @@ on: description: Read-only large phone and tablet export diagnostic (one existing UI journey; not final acceptance) type: boolean default: false + setup_diagnostic: + description: Read-only tablet demo-entry diagnostic (one existing UI journey; not final acceptance) + type: boolean + default: false permissions: contents: read concurrency: @@ -24,13 +28,14 @@ jobs: DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer ORIENTATION_DIAGNOSTIC: ${{ github.event_name == 'workflow_dispatch' && inputs.orientation_diagnostic && 'true' || 'false' }} EXPORT_DIAGNOSTIC: ${{ github.event_name == 'workflow_dispatch' && inputs.export_diagnostic && 'true' || 'false' }} + SETUP_DIAGNOSTIC: ${{ github.event_name == 'workflow_dispatch' && inputs.setup_diagnostic && 'true' || 'false' }} timeout-minutes: 120 name: Build and test (${{ matrix.device }}) strategy: fail-fast: false max-parallel: 6 matrix: - device: ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.export_diagnostic && '["large","tablet"]' || inputs.orientation_diagnostic && '["tablet-landscape"]' || '["compact","phone","large","tablet","tablet-landscape","large-tablet"]') || '["phone","tablet"]') }} + device: ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.setup_diagnostic && '["tablet"]' || inputs.export_diagnostic && '["large","tablet"]' || inputs.orientation_diagnostic && '["tablet-landscape"]' || '["compact","phone","large","tablet","tablet-landscape","large-tablet"]') || '["phone","tablet"]') }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4, verified upstream and actual focused47 job with: @@ -38,7 +43,11 @@ jobs: persist-credentials: false - name: Validate diagnostic selection run: | - if [ "$ORIENTATION_DIAGNOSTIC" = true ] && [ "$EXPORT_DIAGNOSTIC" = true ]; then + diagnostics=0 + for flag in "$ORIENTATION_DIAGNOSTIC" "$EXPORT_DIAGNOSTIC" "$SETUP_DIAGNOSTIC"; do + if [ "$flag" = true ]; then diagnostics=$((diagnostics + 1)); fi + done + if [ "$diagnostics" -gt 1 ]; then echo "Select at most one explicit diagnostic purpose." exit 1 fi @@ -93,7 +102,7 @@ jobs: - name: Compile app and native test bundles run: | set -o pipefail - xcodebuild build-for-testing -project SpaceWorks.xcodeproj -scheme SpaceWorks -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -disableAutomaticPackageResolution -onlyUsePackageVersionsFromResolvedFile -skipPackagePluginValidation "SPACEWORKS_TEST_PROFILE=$DEVICE_PROFILE" ONLY_ACTIVE_ARCH=YES CODE_SIGN_STYLE=Manual CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= | tee build.log + xcodebuild build-for-testing -project SpaceWorks.xcodeproj -scheme SpaceWorks -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -disableAutomaticPackageResolution -onlyUsePackageVersionsFromResolvedFile -skipPackagePluginValidation "SPACEWORKS_TEST_PROFILE=$DEVICE_PROFILE" "SPACEWORKS_SETUP_DIAGNOSTIC=$SETUP_DIAGNOSTIC" ONLY_ACTIVE_ARCH=YES CODE_SIGN_STYLE=Manual CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= | tee build.log - name: Verify reviewed dependency lock run: python3 scripts/dependency_lock.py check - name: Inspect actual built Debug bundle metadata @@ -114,11 +123,14 @@ jobs: 'tablet_orientations': required <= set(info.get('UISupportedInterfaceOrientations~ipad', []))} diagnostic = os.environ['ORIENTATION_DIAGNOSTIC'] == 'true' export_diagnostic = os.environ['EXPORT_DIAGNOSTIC'] == 'true' - assert not (diagnostic and export_diagnostic) + setup_diagnostic = os.environ['SETUP_DIAGNOSTIC'] == 'true' + assert sum([diagnostic, export_diagnostic, setup_diagnostic]) <= 1 + run_attempt = int(os.environ['GITHUB_RUN_ATTEMPT']) + assert run_attempt >= 1 dependency_lock = json.loads(pathlib.Path('dependency-lock-validation.json').read_text(encoding='utf-8')) - report = {'dependency_lock': dependency_lock, 'kind': 'export-diagnostic-built-debug-metadata' if export_diagnostic else 'orientation-diagnostic-built-debug-metadata' if diagnostic else 'canonical-built-debug-metadata', 'source_sha': source, - 'purpose': 'read-only-native-export-diagnostic' if export_diagnostic else 'read-only-orientation-diagnostic' if diagnostic else 'complete-native-validation', - 'ui_selection': 'SpaceWorksUITests/AccountFlowTests/testLargestTextLandscapeExportCanReachNativeShare' if export_diagnostic else 'SpaceWorksUITests/MainFlowTests/testRegularTextDarkLandscapeSignInUsesNativeBrandPanel' if diagnostic else 'SpaceWorksUITests', + report = {'dependency_lock': dependency_lock, 'kind': 'setup-diagnostic-built-debug-metadata' if setup_diagnostic else 'export-diagnostic-built-debug-metadata' if export_diagnostic else 'orientation-diagnostic-built-debug-metadata' if diagnostic else 'canonical-built-debug-metadata', 'source_sha': source, 'run_attempt': run_attempt, + 'purpose': 'read-only-demo-entry-diagnostic' if setup_diagnostic else 'read-only-native-export-diagnostic' if export_diagnostic else 'read-only-orientation-diagnostic' if diagnostic else 'complete-native-validation', + 'ui_selection': 'SpaceWorksUITests/AccountFlowTests/testDeletionBlockerOpensCurrentMakerspacePaymentFlow' if setup_diagnostic else 'SpaceWorksUITests/AccountFlowTests/testLargestTextLandscapeExportCanReachNativeShare' if export_diagnostic else 'SpaceWorksUITests/MainFlowTests/testRegularTextDarkLandscapeSignInUsesNativeBrandPanel' if diagnostic else 'SpaceWorksUITests', 'profile': os.environ['DEVICE_PROFILE'], 'bundle_id': info.get('CFBundleIdentifier'), 'sdk': info.get('DTSDKName'), 'device_families': info.get('UIDeviceFamily'), 'fonts': info.get('UIAppFonts', []), 'scene_manifest': scene, @@ -130,17 +142,19 @@ jobs: timeout-minutes: 10 run: | set -o pipefail - xcodebuild test-without-building -project SpaceWorks.xcodeproj -scheme SpaceWorks -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -disableAutomaticPackageResolution -onlyUsePackageVersionsFromResolvedFile -resultBundlePath NativeTestResults.xcresult -skipPackagePluginValidation "SPACEWORKS_TEST_PROFILE=$DEVICE_PROFILE" ONLY_ACTIVE_ARCH=YES CODE_SIGN_STYLE=Manual CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= -only-testing:SpaceWorksTests | tee -a build.log + xcodebuild test-without-building -project SpaceWorks.xcodeproj -scheme SpaceWorks -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -disableAutomaticPackageResolution -onlyUsePackageVersionsFromResolvedFile -resultBundlePath NativeTestResults.xcresult -skipPackagePluginValidation "SPACEWORKS_TEST_PROFILE=$DEVICE_PROFILE" "SPACEWORKS_SETUP_DIAGNOSTIC=$SETUP_DIAGNOSTIC" ONLY_ACTIVE_ARCH=YES CODE_SIGN_STYLE=Manual CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= -only-testing:SpaceWorksTests | tee -a build.log - name: Run native UI journeys run: | set -o pipefail UI_SELECTION=SpaceWorksUITests - if [ "$EXPORT_DIAGNOSTIC" = true ]; then + if [ "$SETUP_DIAGNOSTIC" = true ]; then + UI_SELECTION=SpaceWorksUITests/AccountFlowTests/testDeletionBlockerOpensCurrentMakerspacePaymentFlow + elif [ "$EXPORT_DIAGNOSTIC" = true ]; then UI_SELECTION=SpaceWorksUITests/AccountFlowTests/testLargestTextLandscapeExportCanReachNativeShare elif [ "$ORIENTATION_DIAGNOSTIC" = true ]; then UI_SELECTION=SpaceWorksUITests/MainFlowTests/testRegularTextDarkLandscapeSignInUsesNativeBrandPanel fi - xcodebuild test-without-building -project SpaceWorks.xcodeproj -scheme SpaceWorks -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -disableAutomaticPackageResolution -onlyUsePackageVersionsFromResolvedFile -resultBundlePath TestResults.xcresult -skipPackagePluginValidation "SPACEWORKS_TEST_PROFILE=$DEVICE_PROFILE" ONLY_ACTIVE_ARCH=YES CODE_SIGN_STYLE=Manual CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= "-only-testing:$UI_SELECTION" | tee -a build.log + xcodebuild test-without-building -project SpaceWorks.xcodeproj -scheme SpaceWorks -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -disableAutomaticPackageResolution -onlyUsePackageVersionsFromResolvedFile -resultBundlePath TestResults.xcresult -skipPackagePluginValidation "SPACEWORKS_TEST_PROFILE=$DEVICE_PROFILE" "SPACEWORKS_SETUP_DIAGNOSTIC=$SETUP_DIAGNOSTIC" ONLY_ACTIVE_ARCH=YES CODE_SIGN_STYLE=Manual CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= "-only-testing:$UI_SELECTION" | tee -a build.log - name: Export UI screenshots if: always() run: | @@ -190,11 +204,32 @@ jobs: report['collection_error_kind'] = type(error).__name__ pathlib.Path('native-presentation-service-metadata.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') PY + - name: Collect read-only demo entry diagnostics + if: always() && env.SETUP_DIAGNOSTIC == 'true' + run: | + python3 - <<'PY' + import json, os, pathlib, subprocess + log = pathlib.Path('demo-entry-system.log') + log.write_text('', encoding='utf-8') + report = {'kind': 'demo-entry-diagnostic-collection-status', 'source_sha': os.environ['GITHUB_SHA'], 'profile': os.environ.get('DEVICE_PROFILE'), 'run_attempt': int(os.environ['GITHUB_RUN_ATTEMPT']), 'window_minutes': 20, 'timeout_seconds': 60, 'collection_success': False} + simulator = os.environ.get('SIMULATOR_ID') + if not simulator: + report['error_kind'] = 'MissingSimulator' + else: + try: + result = subprocess.run(['xcrun', 'simctl', 'spawn', simulator, 'log', 'show', '--last', '20m', '--style', 'compact', '--info', '--predicate', 'subsystem == "org.spaceworks.demo-entry-diagnostics" AND category == "entry"'], capture_output=True, text=True, timeout=60) + log.write_text(result.stdout, encoding='utf-8') + report['collection_exit_code'] = result.returncode + report['collection_success'] = result.returncode == 0 + except (subprocess.TimeoutExpired, OSError) as error: + report['error_kind'] = type(error).__name__ + pathlib.Path('demo-entry-collection.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + PY - name: Archive validation evidence if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: ${{ env.EXPORT_DIAGNOSTIC == 'true' && 'ios-export-diagnostic' || env.ORIENTATION_DIAGNOSTIC == 'true' && 'ios-orientation-diagnostic' || 'ios-validation' }}-${{ matrix.device }} + name: ${{ env.SETUP_DIAGNOSTIC == 'true' && 'ios-setup-diagnostic' || env.EXPORT_DIAGNOSTIC == 'true' && 'ios-export-diagnostic' || env.ORIENTATION_DIAGNOSTIC == 'true' && 'ios-orientation-diagnostic' || 'ios-validation' }}-${{ matrix.device }}${{ github.run_attempt > 1 && format('-attempt-{0}', github.run_attempt) || '' }} path: | NativeTestResults.xcresult TestResults.xcresult @@ -208,6 +243,8 @@ jobs: build-tools.json Dependencies/Package.resolved orientation-system.log + demo-entry-system.log + demo-entry-collection.json native-presentation-service-metadata.json DerivedData/Build/Intermediates.noindex/BuildToolPluginIntermediates/**/GeneratedSources/*.swift SpaceWorks.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/SpaceWorks/DemoMode.swift b/SpaceWorks/DemoMode.swift index 1c5458c..e900a36 100644 --- a/SpaceWorks/DemoMode.swift +++ b/SpaceWorks/DemoMode.swift @@ -103,7 +103,10 @@ struct DemoRoot: View { HStack { Text("Demo mode · sample data").brandFont(.caption, weight: .bold) Spacer() - Button { session.reset(); exit() } label: { + Button { + NativeDemoEntryDiagnostics.record(.exitButtonInvoked) + session.reset(); exit() + } label: { Label("Exit demo", systemImage: "xmark").labelStyle(.iconOnly) .font(.title3).frame(minWidth: 48, minHeight: 48) .contentShape(Rectangle()) @@ -121,7 +124,8 @@ struct DemoRoot: View { .safeAreaInset(edge: .bottom) { MobileLinkNotice(router: linkRouter) } .onChange(of: linkRouter.revision) { _, _ in Task { await linkRouter.process() } } .onChange(of: session.stage) { _, _ in bindRouting() } - .onDisappear { linkRouter.reset() } + .onAppear { NativeDemoEntryDiagnostics.record(.demoRootAppeared) } + .onDisappear { linkRouter.reset(); NativeDemoEntryDiagnostics.record(.demoRootDisappeared) } } private func bindRouting() { guard session.stage == .shell else { linkRouter.unbind(); return } @@ -153,6 +157,7 @@ struct DemoAuthView: View { switch session.stage { case .welcome: Text("Make yourself at home.").brandFont(.largeTitle, weight: .bold) + .onAppear { NativeDemoEntryDiagnostics.record(.welcomeAppeared) } Text("Explore sample makerspaces and complete app flows. Nothing is sent to a server.").foregroundStyle(Color.swMuted) Picker("Demo role", selection: $session.role) { ForEach(DemoSession.Role.allCases, id: \.self) { Text(LocalizedStringKey($0.rawValue)) } } .pickerStyle(.segmented).accessibilityIdentifier("demo-role") diff --git a/SpaceWorks/RootView.swift b/SpaceWorks/RootView.swift index c66ca87..61c4be5 100644 --- a/SpaceWorks/RootView.swift +++ b/SpaceWorks/RootView.swift @@ -1,5 +1,8 @@ import SwiftUI import SpaceWorksAPI +#if DEBUG +import OSLog +#endif struct RootView: View { @State private var state = AppState() @@ -15,7 +18,13 @@ struct RootView: View { IntroductionView { introComplete = true } } else { #if DEBUG - if exploringDemo { DemoRoot { exploringDemo = false } } + if exploringDemo { + DemoRoot { + NativeDemoEntryDiagnostics.record(.rootExitInvoked, exploringDemo: exploringDemo) + exploringDemo = false + NativeDemoEntryDiagnostics.record(.rootExitCompleted, exploringDemo: exploringDemo) + } + } else { liveContent } #else liveContent @@ -49,6 +58,11 @@ struct RootView: View { await state.restore() } .onChange(of: linkRouter.revision) { _, _ in Task { await linkRouter.process() } } + #if DEBUG + .onChange(of: exploringDemo) { _, value in + NativeDemoEntryDiagnostics.record(.rootStateChanged, exploringDemo: value) + } + #endif .onOpenURL { url in #if DEBUG guard !exploringDemo else { return } @@ -85,7 +99,11 @@ struct RootView: View { } #if DEBUG if state.screen == .connect { - Button("Explore demo") { exploringDemo = true }.frame(minHeight: 48).accessibilityIdentifier("explore-demo") + Button("Explore demo") { + NativeDemoEntryDiagnostics.record(.exploreActionEntered, exploringDemo: exploringDemo) + exploringDemo = true + NativeDemoEntryDiagnostics.record(.exploreActionCompleted, exploringDemo: exploringDemo) + }.frame(minHeight: 48).accessibilityIdentifier("explore-demo") .frame(maxWidth: .infinity).background(Color.swSurface) } #endif @@ -345,3 +363,27 @@ private struct MemberScreen: View { } } } + +#if DEBUG +/// Explicit diagnostic observation only: fixed events/booleans, never account or form data. +@MainActor enum NativeDemoEntryDiagnostics { + enum Event: String { + case exploreActionEntered = "explore-action-entered" + case exploreActionCompleted = "explore-action-completed" + case rootStateChanged = "root-state-changed" + case demoRootAppeared = "demo-root-appeared" + case demoRootDisappeared = "demo-root-disappeared" + case welcomeAppeared = "welcome-appeared" + case exitButtonInvoked = "exit-button-invoked" + case rootExitInvoked = "root-exit-invoked" + case rootExitCompleted = "root-exit-completed" + } + private static let logger = Logger(subsystem: "org.spaceworks.demo-entry-diagnostics", category: "entry") + static func record(_ event: Event, exploringDemo: Bool? = nil) { + let arguments = ProcessInfo.processInfo.arguments + guard arguments.contains("--ui-testing"), arguments.contains("--ui-demo-entry-diagnostics") else { return } + let state = exploringDemo.map { $0 ? "true" : "false" } ?? "not-applicable" + logger.notice("SpaceWorksDemoEntry event=\(event.rawValue, privacy: .public) exploringDemo=\(state, privacy: .public)") + } +} +#endif diff --git a/SpaceWorksUITests/AccountFlowTests.swift b/SpaceWorksUITests/AccountFlowTests.swift index 563170e..b2126fa 100644 --- a/SpaceWorksUITests/AccountFlowTests.swift +++ b/SpaceWorksUITests/AccountFlowTests.swift @@ -4,8 +4,16 @@ final class AccountFlowTests: XCTestCase { override func setUp() { continueAfterFailure = false; XCUIDevice.shared.orientation = .portrait } private func tap(_ element: XCUIElement, in app: XCUIApplication) { NativeUIActions.tap(element, in: app) } private func account(extra: [String] = [], withoutMakerspace: Bool = false) -> XCUIApplication { - let app = XCUIApplication(); app.launchArguments = ["--ui-testing"] + extra; app.launchForDeviceProfile() - tap(app.buttons["explore-demo"], in: app); tap(app.buttons["Continue as member"], in: app) + let app = XCUIApplication(); app.launchArguments = ["--ui-testing"] + extra + if ProcessInfo.processInfo.environment["SPACEWORKS_SETUP_DIAGNOSTIC"] == "true" { + app.launchArguments.append("--ui-demo-entry-diagnostics") + } + app.launchForDeviceProfile() + let explore = app.buttons["explore-demo"] + recordDemoEntry("before native Explore", element: explore, in: app) + tap(explore, in: app) + recordDemoEntry("after native Explore", element: explore, in: app) + tap(app.buttons["Continue as member"], in: app) let username = app.textFields["demo-username"]; tap(username, in: app); username.typeText("member\n") app.secureTextFields["demo-password"].typeText("sample\n"); tap(app.buttons["demo-sign-in"], in: app) if !withoutMakerspace { @@ -38,6 +46,16 @@ final class AccountFlowTests: XCTestCase { XCTAssertTrue(app.navigationBars["Account and security"].waitForExistence(timeout: 5)) return app } + private func recordDemoEntry(_ phase: String, element: XCUIElement, in app: XCUIApplication) { + guard ProcessInfo.processInfo.environment["SPACEWORKS_SETUP_DIAGNOSTIC"] == "true" else { return } + let exists = element.exists + print("SpaceWorksDemoEntryTest \(phase) appFrame=\(app.frame) exploreExists=\(exists) exploreFrame=\(exists ? String(describing: element.frame) : "unavailable")") + XCTContext.runActivity(named: "Demo entry " + phase) { activity in + let pixels = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + pixels.name = "Demo entry " + phase; pixels.lifetime = .keepAlways + activity.add(pixels) + } + } private func confirmNativeDialog(_ title: String, action label: String, in app: XCUIApplication) { if title == "Leave this makerspace?" || title == "Cancel membership renewal?" { let alert = app.alerts.firstMatch diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1f9d9c9..3a2949b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,15 @@ # Phase notes +## C4 — Read-only demo entry diagnostic checkpoint (2026-10-18) + +The exact `ed2aca4` normal-tablet run passed all 302 native cases and 36 of 37 UI cases. The deletion-blocker journey stopped during demo setup: after one native Explore activation, Continue as member remained absent through the existing bounded search. Actual full-OS recording stayed on Connect; the synthesized 50 ms tap was centered on the visible Explore label. Shallow accessibility snapshots contained no descendants. Neither an account deletion/payment defect nor the cause of the missing demo destination is established. + +Add a third, explicitly opted-in setup diagnostic for tablet and that same existing journey, mutually exclusive with orientation/export diagnostics. Default push/PR two-profile and manual six-profile validation keep all 302 native and 37 UI cases. The test scheme defaults its setup flag to the literal false; only an explicit true diagnostic passes the additional app flag. Debug code then records fixed enum events and the demo-entry boolean around the existing action, state transition, welcome appearance, disappearance and exit. No account/form values, IDs or raw app state are logged. Diagnostic-only pre/post action pixels and frames supplement the narrow log. A fixed-subsystem collector has a 60-second process bound and archives source/profile/run-attempt status with its exit code or error kind, without raw exceptions or environment contents. Absence of events is interpretable only after collection success is verified. Existing task ownership, callbacks, gestures, waits, assertions and Release behavior remain unchanged. This checkpoint supplies missing observation, not a product fix or final acceptance. + +Actual Debug metadata also records the integer GitHub run attempt. Evidence artifact names retain their original first-attempt form and add an attempt suffix only for later attempts. Immutable earlier artifacts are neither overwritten nor deleted; no retry is initiated by this change. + +Parity: no screen, text, authority or Android behavior changes. Actual native compilation, diagnostic trace and fresh complete validation remain pending. + ## C3 — Guard native navigation readiness geometry (2026-10-18) In the historical `775f298` tablet-landscape Guide journey, XCTest exposed infinite origins and zero sizes for the native window and Activity target while evaluating a raw `isHittable` predicate. Actual full-OS frames showed the sidebar and Activity row; no persistent missing content or product navigation cause was established. The shared test readiness predicate now requires finite, positive app and target frames before querying native enabled/hittable state. Apply that same predicate to the eight existing sidebar helpers and its failed-readiness diagnostic. Every existing three/ten-second wait, single native sidebar/item activation, content gesture bound and guide/account/member/staff/payment outcome remains unchanged. No app code, timeout extension, synthetic navigation or repeated input is added; fresh native proof remains required. diff --git a/project.yml b/project.yml index 697fd1d..648d6b0 100644 --- a/project.yml +++ b/project.yml @@ -11,6 +11,7 @@ settings: base: SWIFT_VERSION: '5.0' SPACEWORKS_TEST_PROFILE: phone + SPACEWORKS_SETUP_DIAGNOSTIC: "false" SWIFT_EMIT_LOC_STRINGS: YES CODE_SIGN_STYLE: Automatic CURRENT_PROJECT_VERSION: 1 @@ -97,4 +98,5 @@ schemes: gatherCoverageData: true environmentVariables: SPACEWORKS_TEST_PROFILE: $(SPACEWORKS_TEST_PROFILE) + SPACEWORKS_SETUP_DIAGNOSTIC: $(SPACEWORKS_SETUP_DIAGNOSTIC) targets: [SpaceWorksTests, SpaceWorksUITests] From a62a115e7e496d002e62b3699ce300514a99e8a6 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:19:54 +0530 Subject: [PATCH 80/98] test(c5): request profile orientation after launch Co-Authored-By: Codex --- SpaceWorksUITests/NativeTestLaunch.swift | 2 +- docs/CHANGELOG.md | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/SpaceWorksUITests/NativeTestLaunch.swift b/SpaceWorksUITests/NativeTestLaunch.swift index bf3cb21..7a7a4f1 100644 --- a/SpaceWorksUITests/NativeTestLaunch.swift +++ b/SpaceWorksUITests/NativeTestLaunch.swift @@ -47,8 +47,8 @@ extension XCUIApplication { let reduceMotion = profile == "large-tablet" launchEnvironment["SPACEWORKS_TEST_APPEARANCE"] = dark ? "dark" : "light" launchEnvironment["SPACEWORKS_TEST_REDUCE_MOTION"] = reduceMotion ? "1" : "0" - XCUIDevice.shared.orientation = landscape ? .landscapeLeft : .portrait launch() + XCUIDevice.shared.orientation = landscape ? .landscapeLeft : .portrait let orientation = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in landscape ? self.frame.width > self.frame.height : self.frame.height > self.frame.width }, object: nil) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3a2949b..521d585 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,13 @@ # Phase notes +## C5 — Request the test profile orientation after native launch (2026-10-18) + +The exact `ed2aca4` full tablet-landscape profile failed all 37 UI journeys at their unchanged orientation guards: 34 launch checks and three later foreground rotations. Actual failure-time full-OS pixels stayed portrait. Attach/activation scene samples preceded the relevant request and cannot establish failure-time effective policy. A separate same-source fresh-runner diagnostic passed all 302 native cases and the existing regular-text dark sign-in journey: its portrait launch followed by a foreground landscape request produced actual landscape pixels, the required app frame and settled landscape scene samples. This does not explain the historical full-profile failure, including its three later foreground failures, or establish an app/OS cause. + +Move the existing single requested profile-orientation assignment from immediately before native launch to immediately after it, before the same ten-second actual-frame expectation. This is a bounded harness ordering experiment for the foreground application, without a second request, delay, retry, geometry override or app policy change. All profile appearance checks, later rotation guards, native actions, 302 native cases and 37 UI outcomes remain. No prior focused result substitutes for fresh complete validation; actual new-source landscape and all six-profile/native/Release/visual gates remain pending. + +Parity: test launch sequencing only; application, strings, navigation and Android behavior are unchanged. + ## C4 — Read-only demo entry diagnostic checkpoint (2026-10-18) The exact `ed2aca4` normal-tablet run passed all 302 native cases and 36 of 37 UI cases. The deletion-blocker journey stopped during demo setup: after one native Explore activation, Continue as member remained absent through the existing bounded search. Actual full-OS recording stayed on Connect; the synthesized 50 ms tap was centered on the visible Explore label. Shallow accessibility snapshots contained no descendants. Neither an account deletion/payment defect nor the cause of the missing demo destination is established. From caf966368f5954f0510ac7eb6a66cb583c6b5331 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:17:21 +0530 Subject: [PATCH 81/98] Fix unread Inbox heading at accessibility sizes Give the title and unread status separate full-width rows while preserving native accessibility children. Extend the existing largest-text notification journey with whole-heading native visibility proof. Co-Authored-By: Codex --- SpaceWorks/MemberViews.swift | 15 ++++++++++++- .../NotificationsFlowTests.swift | 22 +++++++++++++++++++ docs/CHANGELOG.md | 8 +++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/SpaceWorks/MemberViews.swift b/SpaceWorks/MemberViews.swift index 95ffc1c..c7166c7 100644 --- a/SpaceWorks/MemberViews.swift +++ b/SpaceWorks/MemberViews.swift @@ -359,6 +359,7 @@ struct InventoryDetail: View { struct MemberInbox: View { @Bindable var state: MemberState + @Environment(\.dynamicTypeSize) private var dynamicTypeSize var paymentsAvailable = false var openLink: ((String) -> Void)? = nil var body: some View { @@ -371,7 +372,19 @@ struct MemberInbox: View { if state.inboxLoad == .ready && state.messages.isEmpty { SWCard { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } } ForEach(state.messages, id: \.id) { message in SWCard { - HStack { Text(message.title).brandFont(.headline); Spacer(); if message.read_at == nil { Label("Unread", systemImage: "circle.fill").brandFont(.caption).foregroundStyle(Color.swAccentInk) } } + if dynamicTypeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 8) { + Text(message.title).brandFont(.headline) + if message.read_at == nil { + Label("Unread", systemImage: "circle.fill").brandFont(.caption).foregroundStyle(Color.swAccentInk) + .accessibilityIdentifier("inbox-unread-\(message.id)") + } + }.frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("inbox-heading-\(message.id)") + } else { + HStack { Text(message.title).brandFont(.headline); Spacer(); if message.read_at == nil { Label("Unread", systemImage: "circle.fill").brandFont(.caption).foregroundStyle(Color.swAccentInk).accessibilityIdentifier("inbox-unread-\(message.id)") } } + } Text(message.summary).foregroundStyle(Color.swMuted) Text(message.created_at.formatted(date: .abbreviated, time: .shortened)).brandFont(.caption).foregroundStyle(Color.swMuted) if let openLink, !message.link.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { diff --git a/SpaceWorksUITests/NotificationsFlowTests.swift b/SpaceWorksUITests/NotificationsFlowTests.swift index 3ff87ab..ff52e69 100644 --- a/SpaceWorksUITests/NotificationsFlowTests.swift +++ b/SpaceWorksUITests/NotificationsFlowTests.swift @@ -47,6 +47,28 @@ final class NotificationsFlowTests: XCTestCase { capture("Notification permission and authorization refusal") tap(app.buttons["Dismiss"], app) XCTAssertTrue(app.buttons["demo-push-product"].exists) + tap(app.buttons["demo-push-other-space"], app) + XCTAssertTrue(app.buttons["Mark all as read"].waitForExistence(timeout: 8)) + XCTAssertTrue(app.buttons["Maker Space Bengaluru"].exists) + let unread = app.staticTexts.matching(NSPredicate(format: "identifier BEGINSWITH %@", "inbox-unread-")).firstMatch + XCTAssertTrue(unread.waitForExistence(timeout: 8)) + XCTAssertEqual(unread.label, "Unread") + let heading = app.descendants(matching: .any).matching(NSPredicate(format: "identifier BEGINSWITH %@", "inbox-heading-")).firstMatch + XCTAssertTrue(heading.waitForExistence(timeout: 8)) + // Reveal the actual containing title/status group with the existing helper. + // This group has no action handler; the native tap does not mark it read. + NativeUIActions.tap(heading, in: app, requireWholeFrame: true) + let title = app.staticTexts["Your kit is ready"].firstMatch + XCTAssertTrue(title.exists) + let readiness = NativeUIActions.readiness(in: app) + let top = app.navigationBars.allElementsBoundByIndex.filter { readiness.evaluate(with: $0) }.map { $0.frame.maxY }.max() ?? app.frame.minY + let bottom = app.tabBars.allElementsBoundByIndex.filter { readiness.evaluate(with: $0) }.map { $0.frame.minY }.min() ?? app.frame.maxY + let viewport = CGRect(x: app.frame.minX, y: top, width: app.frame.width, height: max(0, bottom - top)).insetBy(dx: 2, dy: 2) + for element in [title, unread] { + XCTAssertTrue(readiness.evaluate(with: element)) + XCTAssertTrue(viewport.contains(element.frame), "The whole Inbox title and unread status must be visible.") + } + capture("Native unread Inbox heading at largest text") } func testNotificationRefetchOpensProductAndAnotherAuthorizedMakerspaceInboxAcrossRotation() { let app = notifications(); tap(app.buttons["enable-demo-notifications"], app) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 521d585..562799e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,13 @@ # Phase notes +## C6 — Give unread Inbox status its own accessibility row (2026-10-18) + +Actual full-OS `ed2aca4` large-phone pixels showed the card's Unread status fragmented as Unrea/d beside its title at largest Dynamic Type. The current application's Inbox row was byte-identical. At accessibility text sizes, stack the same message title and existing Unread label vertically with full available width; retain the ordinary horizontal layout, supplied semantic fonts, symbol/color, text, read state and all callbacks. Native accessibility containment preserves the separately spoken title and status children, with stable per-message group and status identifiers. No font cap, truncation or data change is introduced. + +Extend the existing forced-AX5 permission/refusal journey only after all its original outcomes: follow the existing authorized-other-makerspace Inbox link, require the actual unread status, reveal its containing heading using the same bounded native helper and independently assert that both title and status fit the native bar-adjusted viewport. Capture the full-OS Inbox heading for fresh review on all six profiles. There are still 302 native, 37 UI and 45 Python cases. Actual new-source compact/large text fit and complete native/Release/visual gates remain pending; prior source results are diagnostic only. + +Parity: shared live/demo Inbox keeps the same title, status, authority and actions. This is an iOS accessibility layout correction; no backend, contract or Android source changes. + ## C5 — Request the test profile orientation after native launch (2026-10-18) The exact `ed2aca4` full tablet-landscape profile failed all 37 UI journeys at their unchanged orientation guards: 34 launch checks and three later foreground rotations. Actual failure-time full-OS pixels stayed portrait. Attach/activation scene samples preceded the relevant request and cannot establish failure-time effective policy. A separate same-source fresh-runner diagnostic passed all 302 native cases and the existing regular-text dark sign-in journey: its portrait launch followed by a foreground landscape request produced actual landscape pixels, the required app frame and settled landscape scene samples. This does not explain the historical full-profile failure, including its three later foreground failures, or establish an app/OS cause. From d4ebfa46cbfc1fab71a4b5a5a9944fbe2faa7ef7 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:33:02 +0530 Subject: [PATCH 82/98] Preserve independent push and pull-request validation Scope normal CI concurrency by event so required push checks can finish alongside pull-request checks. Keep manual matrix isolation and every native gate unchanged. Co-Authored-By: Codex --- .github/workflows/ios.yml | 2 +- docs/CHANGELOG.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 4438538..81531ba 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -19,7 +19,7 @@ on: permissions: contents: read concurrency: - group: ios-${{ github.event_name == 'workflow_dispatch' && format('full-{0}', github.run_id) || format('{0}-{1}', github.event.pull_request.head.repo.full_name || github.repository, github.head_ref || github.ref_name) }} + group: ios-${{ github.event_name == 'workflow_dispatch' && format('full-{0}', github.run_id) || format('{0}-{1}-{2}', github.event_name, github.event.pull_request.head.repo.full_name || github.repository, github.head_ref || github.ref_name) }} cancel-in-progress: true jobs: build-and-test: diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 562799e..9fe6ca0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,11 @@ # Phase notes +## C7 — Preserve independent push and pull-request validation (2026-10-18) + +The normal push and pull-request workflows shared a concurrency key for the same repository and branch, so the newer pull-request event canceled the same-head push run. GitHub's required-check inventory can retain that canceled push check and require a separate rerun after the pull-request jobs finish. Include the event name in the normal concurrency group so both event types can complete independently, while newer runs still cancel older runs of their own event and branch. Manual validation retains its unique run-ID concurrency group. + +No app, test selection, outcome, timeout, SDK, action pin, permission or diagnostic flag changes. Push and pull-request validation still run both representative profiles, and the default manual matrix still runs all six profiles with every 302 native and 37 UI case. Actual new-source canonical, complete-matrix, Release and visual proof remain mandatory and pending. + ## C6 — Give unread Inbox status its own accessibility row (2026-10-18) Actual full-OS `ed2aca4` large-phone pixels showed the card's Unread status fragmented as Unrea/d beside its title at largest Dynamic Type. The current application's Inbox row was byte-identical. At accessibility text sizes, stack the same message title and existing Unread label vertically with full available width; retain the ordinary horizontal layout, supplied semantic fonts, symbol/color, text, read state and all callbacks. Native accessibility containment preserves the separately spoken title and status children, with stable per-message group and status identifiers. No font cap, truncation or data change is introduced. From a9312aa9a450dc33d5fff6b49f953fe7a02be37e Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:20:45 +0530 Subject: [PATCH 83/98] feat(d1): re-pin the mobile backend contract and adopt vendor payments Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- Fixtures/Member/bookings.json | 3 +- Fixtures/Member/bootstrap.json | 4 + Fixtures/Member/events.json | 7 +- Fixtures/Staff/booking.json | 6 +- Fixtures/Staff/event.json | 9 + Fixtures/Staff/registration.json | 4 + Fixtures/Staff/space.json | 15 +- .../MemberActionsOperations.swift | 15 +- .../Sources/SpaceWorksAPI/Networking.swift | 10 +- .../SpaceWorksAPI/PaymentOperations.swift | 17 + .../SpaceWorksAPI/StaffOperations.swift | 2 +- .../openapi-generator-config.yaml | 18 + SpaceWorks/BookingState.swift | 2 +- SpaceWorks/BookingViews.swift | 4 +- SpaceWorks/DebugAuthFixtures.swift | 6 +- SpaceWorks/DemoMemberActionsRepository.swift | 8 +- SpaceWorks/LiveMemberActionsRepository.swift | 2 +- SpaceWorks/MemberActionsState.swift | 2 +- SpaceWorks/PaymentsState.swift | 2 +- SpaceWorks/PaymentsViews.swift | 15 +- SpaceWorks/Platform/NativePayments.swift | 7 +- SpaceWorks/StaffDemo.swift | 4 +- SpaceWorks/StaffRepository.swift | 4 +- SpaceWorksTests/AccountOperationTests.swift | 2 +- .../MemberActionsNetworkingTests.swift | 2 +- SpaceWorksTests/NativePaymentsTests.swift | 2 +- SpaceWorksTests/PaymentsTests.swift | 38 +- api/openapi.json | 132006 +++++++++------ api/provenance.json | 17 +- docs/mobile-auth-ux-spec.md | 2 +- scripts/tests/test_generation.py | 2 +- scripts/tests/test_member_fixtures.py | 66 + 32 files changed, 78020 insertions(+), 54283 deletions(-) create mode 100644 scripts/tests/test_member_fixtures.py diff --git a/Fixtures/Member/bookings.json b/Fixtures/Member/bookings.json index b17bca8..cdb9bf7 100644 --- a/Fixtures/Member/bookings.json +++ b/Fixtures/Member/bookings.json @@ -11,7 +11,8 @@ "ends_at": "2026-10-20T11:00:00Z", "status": "confirmed", "cancellation_cutoff_at": "2026-10-19T09:00:00Z", - "can_cancel": true + "can_cancel": true, + "confirmation_available": false } ] } diff --git a/Fixtures/Member/bootstrap.json b/Fixtures/Member/bootstrap.json index 89d7fff..4f1f0f3 100644 --- a/Fixtures/Member/bootstrap.json +++ b/Fixtures/Member/bootstrap.json @@ -45,5 +45,9 @@ "base_url": "https://demo.invalid", "publishable_key": "", "inventory_path": "/api/v1/public/fab-lab-kochi/inventory/" + }, + "messaging": { + "sms": false, + "whatsapp": false } } diff --git a/Fixtures/Member/events.json b/Fixtures/Member/events.json index 0f92734..d075de5 100644 --- a/Fixtures/Member/events.json +++ b/Fixtures/Member/events.json @@ -26,6 +26,11 @@ "image_url": null, "status": "published", "organizers": [], - "series": null + "series": null, + "audience_label": null, + "participating_organizations": [], + "registration_count": null, + "show_registration_count": false, + "website": null } ] diff --git a/Fixtures/Staff/booking.json b/Fixtures/Staff/booking.json index af295a0..a987cad 100644 --- a/Fixtures/Staff/booking.json +++ b/Fixtures/Staff/booking.json @@ -1,4 +1,6 @@ { + "arrived_at": null, + "cancelled_at": null, "created_at": "2026-10-16T09:00:00Z", "custom_answers": null, "email": "meera@example.test", @@ -9,7 +11,9 @@ "payment": null, "phone": "", "public_token": "71000000-0000-4000-8000-000000000001", + "restore_until": null, "space_id": 901, "starts_at": "2026-10-16T09:00:00Z", - "status": "pending" + "status": "pending", + "status_before_cancel": "" } diff --git a/Fixtures/Staff/event.json b/Fixtures/Staff/event.json index f8506a2..cf5fe96 100644 --- a/Fixtures/Staff/event.json +++ b/Fixtures/Staff/event.json @@ -1,4 +1,8 @@ { + "audience_label": null, + "badge_sensitive_fields_allowed": false, + "broadcasts_enabled": false, + "can_manage_staff": false, "cancellation_cutoff_hours": null, "capacity": 20, "category_id": null, @@ -15,7 +19,9 @@ "location_kind": "indoor", "makerspace_id": 7, "offline_checkin_enabled": false, + "organization_admission_blocked": false, "organizers": [], + "participating_organizations": [], "payment_amount": "0.00", "registration_audience": "members", "registration_counts": { @@ -30,9 +36,12 @@ "registration_cutoff_lead_minutes": null, "registration_open": false, "registration_requires_approval": false, + "report_features": [], "series_override_fields": null, "series_revision": null, "series_summary": null, + "show_registration_count": false, + "staff_permissions": [], "starts_at": "2026-10-16T09:00:00Z", "status": "published", "tags": null, diff --git a/Fixtures/Staff/registration.json b/Fixtures/Staff/registration.json index 33e8432..adad19a 100644 --- a/Fixtures/Staff/registration.json +++ b/Fixtures/Staff/registration.json @@ -1,4 +1,5 @@ { + "cancelled_at": null, "created_at": "2026-10-16T09:00:00Z", "custom_answers": null, "discount_amount": "0.00", @@ -6,9 +7,12 @@ "event_id": 801, "id": 1101, "name": "Meera Nair", + "no_longer_eligible": false, "payment": null, "phone": "", "quoted_amount": null, + "restore_until": null, "status": "registered", + "status_before_cancel": "", "ticket_type_id": null } diff --git a/Fixtures/Staff/space.json b/Fixtures/Staff/space.json index 8f55f3e..74cf9a1 100644 --- a/Fixtures/Staff/space.json +++ b/Fixtures/Staff/space.json @@ -1,15 +1,21 @@ { "approval_mode": "instant", "booking_lead_time_minutes": 0, + "broadcasts_enabled": false, "buffer_after_minutes": 0, "buffer_before_minutes": 0, + "can_copy_booking_rules": false, + "can_manage_grants": false, "cancellation_cutoff_hours": 0, "capacity": 8, + "checkin_enabled": false, + "clone_enabled": false, "created_at": "2026-10-16T09:00:00Z", "created_by_id": null, "custom_form": null, "description": "", "effective_requester_notifications_enabled": false, + "embed_enabled": false, "id": 901, "image_url": "", "is_active": true, @@ -22,10 +28,17 @@ "max_booking_duration_minutes": 0, "min_booking_duration_minutes": 0, "name": "Sample project room", + "passes_enabled": false, "payment_amount": "0.00", "public_token": "71000000-0000-4000-8000-000000000001", + "recurring_enabled": false, + "report_features": [], "requester_notifications_enabled": null, "show_public_availability": false, "show_public_booker_names": false, - "updated_at": "2026-10-16T09:00:00Z" + "staff_grants_enabled": false, + "staff_permissions": [], + "staff_tools_enabled": false, + "updated_at": "2026-10-16T09:00:00Z", + "website_enabled": false } diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/MemberActionsOperations.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/MemberActionsOperations.swift index 74be456..b750d8e 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/MemberActionsOperations.swift +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/MemberActionsOperations.swift @@ -1,6 +1,19 @@ import Foundation import OpenAPIRuntime +/// The generated 201 body: a confirmed/pending status, optionally with the member's booking token. +public typealias MemberBookingCreated = Operations.api_v1_public_spaces_book_create.Output.Created.Body.jsonPayload + +public extension MemberBookingCreated { + /// Pending approval is distinct from a confirmed booking in both documented response forms. + var pending: Bool { + switch self { + case .case1(let value): return value.status == .pending + case .case2(let value): return value.status == .pending + } + } +} + extension SpaceWorksService { public func memberBootstrap(slug: String) async throws -> Components.Schemas.TenantBootstrap { try await client.api_v1_bootstrap_retrieve(.init(query: .init(slug: slug))).ok.body.json @@ -17,7 +30,7 @@ extension SpaceWorksService { try await client.api_v1_public_spaces_availability_retrieve(.init(path: .init(makerspace_slug: slug, public_token: token), query: .init(ends_at: end, starts_at: start))).ok.body.json } - public func createMemberBooking(slug: String, token: String, input: Components.Schemas.PublicBookingInput) async throws -> Components.Schemas.PublicBookingResponse { + public func createMemberBooking(slug: String, token: String, input: Components.Schemas.PublicBookingInput) async throws -> MemberBookingCreated { try await client.api_v1_public_spaces_book_create(.init(path: .init(makerspace_slug: slug, public_token: token), body: .json(input))).created.body.json } public func cancelMemberBooking(slug: String, token: String) async throws -> Components.Schemas.MemberBooking { diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift index 873030e..5219b12 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/Networking.swift @@ -83,6 +83,14 @@ public struct APIError: Error, LocalizedError, Sendable, Equatable { } } +/// App Attest and development proofs travel in the schema's open verifier alternative. The shared +/// generated `type` enum lists only `play_integrity` (PRD §7), so Apple's wire `type` is kept verbatim. +public enum DeviceAttestation { + public static func payload(_ json: Data) throws -> Components.Schemas.DeviceAttestationPayload { + .init(value4: try JSONDecoder().decode(OpenAPIObjectContainer.self, from: json)) + } +} + /// Local credentials, not a hand-written API payload. Persist together in Keychain in A1. public struct TokenPair: Sendable, Equatable, Codable { public let access: String @@ -394,7 +402,7 @@ public final class SpaceWorksService: Sendable { attestationJSON: Data) async throws -> Components.Schemas.DeviceLoginResult { let input = try Components.Schemas.DeviceLogin(platform: .apple, app_id: appID, environment: development ? .development : .production, username: username, password: password, challenge: challenge, - attestation: JSONDecoder().decode(OpenAPIValueContainer.self, from: attestationJSON)) + attestation: DeviceAttestation.payload(attestationJSON)) return try await publicClient.api_v1_auth_device_login_create(.init(body: .json(input))).ok.body.json } public func me() async throws -> Components.Schemas.AuthUserPayload { diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PaymentOperations.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PaymentOperations.swift index 7742958..0c77e61 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PaymentOperations.swift +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PaymentOperations.swift @@ -9,6 +9,23 @@ public enum PaymentOperationFailure { } } +/// The server selects the vendor; every documented variant names the charge it prepared. +public extension Components.Schemas.MobilePaymentIntentResponse { + var paymentID: Int { + switch self { + case .stripe(let value): return value.payment_id + case .razorpay(let value): return value.payment_id + } + } + /// Required SDK values are present. Format checks happen at the native SDK boundary. + var hasCheckoutParameters: Bool { + switch self { + case .stripe(let value): return !value.client_secret.isEmpty && !value.publishable_key.isEmpty + case .razorpay(let value): return !value.key_id.isEmpty && !value.order_id.isEmpty && value.amount_minor > 0 && !value.currency.isEmpty + } + } +} + /// A download grant authorizes a documented API operation; it is not an arbitrary browser URL. public enum PaymentDownloadGrant { public static func token(from value: String, documentID: Int, origin: ServerOrigin) throws -> String { diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/StaffOperations.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/StaffOperations.swift index 43f13b7..eefccca 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/StaffOperations.swift +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/StaffOperations.swift @@ -61,7 +61,7 @@ extension SpaceWorksService { public func staffResolveCheckIn(eventID: Int, body: Components.Schemas.EventCheckInResolveRequest) async throws -> Components.Schemas.EventCheckInResolveResponse { try await client.api_v1_admin_events_check_in_resolve_create(.init(path: .init(id: eventID), body: .json(body))).ok.body.json } - public func staffMarkAttended(registrationID: Int, body: Components.Schemas.EventAttendanceMark) async throws -> Components.Schemas.EventRegistrationAdmin { + public func staffMarkAttended(registrationID: Int, body: Components.Schemas.EventAttendanceMark) async throws -> Components.Schemas.EventAttendanceResult { try await client.api_v1_admin_event_registrations_mark_attended_create(.init(path: .init(id: registrationID), body: .json(body))).ok.body.json } public func staffSpaces(makerspaceID: Int, page: Int) async throws -> Components.Schemas.BookableSpaceListResponse { diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml index 5203ea6..2d6cc58 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml @@ -100,3 +100,21 @@ filter: - api_v1_auth_phone_destroy - api_v1_auth_social_providers_list - api_v1_auth_social_providers_destroy + + + - api_v1_auth_social_nonce_create + - api_v1_auth_social_google_create + - api_v1_auth_social_apple_create + - api_v1_auth_social_oidc_create + - api_v1_auth_phone_login_start_create + - api_v1_auth_phone_login_confirm_create + - api_v1_auth_logout_all_create + - api_v1_auth_account_deletion_retrieve + - api_v1_auth_account_deletion_create + - api_v1_auth_reauth_retrieve + - api_v1_auth_reauth_create + - api_v1_auth_reauth_phone_start_create + - api_v1_auth_reauth_phone_confirm_create + - api_v1_auth_reauth_social_start_create + - api_v1_auth_reauth_social_complete_create + - api_v1_auth_reauth_social_factor_create diff --git a/SpaceWorks/BookingState.swift b/SpaceWorks/BookingState.swift index ad3e695..46c086a 100644 --- a/SpaceWorks/BookingState.swift +++ b/SpaceWorks/BookingState.swift @@ -9,7 +9,7 @@ final class BookingState { var start = Date().addingTimeInterval(3600) { didSet { if oldValue != start { changed() } } } var end = Date().addingTimeInterval(7200) { didSet { if oldValue != end { changed() } } } private(set) var availability: Components.Schemas.PublicSpaceAvailability? - private(set) var result: Components.Schemas.PublicBookingResponse? + private(set) var result: MemberBookingCreated? private(set) var busy = false private(set) var error: String? private(set) var retryAfter: Date? diff --git a/SpaceWorks/BookingViews.swift b/SpaceWorks/BookingViews.swift index ab02df7..9122e54 100644 --- a/SpaceWorks/BookingViews.swift +++ b/SpaceWorks/BookingViews.swift @@ -49,8 +49,8 @@ struct SpaceBookingView: View { if isDemo { Text("Demo mode · no real booking is made").brandFont(.caption, weight: .bold).foregroundStyle(Color.swMuted) } if let result = state.result { SWCard { - Label(result.status.value1 == .pending ? "Booking request received" : "Space booked", systemImage: "checkmark.circle").brandFont(.title2, weight: .bold) - Text(result.status.value1 == .pending ? "Staff must approve this request before the slot is confirmed." : "Your selected slot is confirmed and booked.").foregroundStyle(Color.swMuted) + Label(result.pending ? "Booking request received" : "Space booked", systemImage: "checkmark.circle").brandFont(.title2, weight: .bold) + Text(result.pending ? "Staff must approve this request before the slot is confirmed." : "Your selected slot is confirmed and booked.").foregroundStyle(Color.swMuted) Text("Find your booking in Activity. Its reference and cancellation options are loaded from your account.").foregroundStyle(Color.swMuted) Button("View bookings", action: viewActivity).buttonStyle(SWPrimary()) Button("Book another slot") { form.text = [:]; form.multiple = [:]; state.startAnother() }.frame(minHeight: 48) diff --git a/SpaceWorks/DebugAuthFixtures.swift b/SpaceWorks/DebugAuthFixtures.swift index 497a342..e68c2d8 100644 --- a/SpaceWorks/DebugAuthFixtures.swift +++ b/SpaceWorks/DebugAuthFixtures.swift @@ -4,11 +4,11 @@ import Foundation /// Contract-shaped fixtures for UI tests only, selected by explicit process launch flags. enum DebugAuthFixtures { static let grant = #"{"id":"00000000-0000-0000-0000-000000000001","platform":"apple","app_id":"org.spaceworks.app","environment":"development","status":"active","attested_at":"2026-10-16T12:00:00Z","last_used_at":"2026-10-16T12:00:00Z","created_at":"2026-10-16T12:00:00Z"}"# - static let membership = #"{"id":7,"slug":"demo-workshop","role":"member","role_id":1,"role_name":"Member","role_slug":"member","source":"membership","actions":[],"can_configure_machine_types":false,"is_machine_only":false,"can_refer":false,"can_verify":false,"verified_at":null,"referrals_enabled":false}"# + static let membership = #"{"id":7,"slug":"demo-workshop","role":"member","role_id":1,"role_ids":[1],"role_slugs":["member"],"role_names":["Member"],"role_name":"Member","role_slug":"member","source":"membership","actions":[],"can_configure_machine_types":false,"is_machine_only":false,"can_refer":false,"can_verify":false,"verified_at":null,"referrals_enabled":false}"# static let secondMembership = membership.replacingOccurrences(of: #""id":7"#, with: #""id":8"#).replacingOccurrences(of: "demo-workshop", with: "community-lab") static var user: String { user(memberships: [membership, secondMembership]) } - static func user(memberships: [String], superuser: Bool = false) -> String { - "{\"id\":12,\"username\":\"member\",\"email\":\"member@example.invalid\",\"display_name\":\"Maker\",\"phone\":\"\",\"email_verified\":true,\"role\":\"member\",\"is_superuser\":\(superuser),\"must_change_password\":false,\"makerspaces\":[\(memberships.joined(separator: ","))]}" + static func user(memberships: [String], superuser: Bool = false, authority: String = "member") -> String { + "{\"session_authority\":\"\(authority)\",\"id\":12,\"username\":\"member\",\"email\":\"member@example.invalid\",\"display_name\":\"Maker\",\"phone\":\"\",\"email_verified\":true,\"role\":\"member\",\"is_superuser\":\(superuser),\"must_change_password\":false,\"makerspaces\":[\(memberships.joined(separator: ","))]}" } static var tokens: String { "{\"access\":\"test-access\",\"refresh\":\"test-refresh\",\"user\":\(user),\"device_grant\":\(grant)}" } static let mfa = #"{"mfa_required":true,"enrollment_required":false,"challenge":"test-mfa-challenge-of-valid-length","expires_in":300}"# diff --git a/SpaceWorks/DemoMemberActionsRepository.swift b/SpaceWorks/DemoMemberActionsRepository.swift index c3913e0..4db1c86 100644 --- a/SpaceWorks/DemoMemberActionsRepository.swift +++ b/SpaceWorks/DemoMemberActionsRepository.swift @@ -27,7 +27,7 @@ actor DemoMemberStore { var value = try object(bookingsJSON); var rows = value["results"] as? [[String: Any]] ?? [] let format = ISO8601DateFormatter() rows.insert(["public_token": UUID().uuidString, "space_name": "Workshop bench", "timezone": "Asia/Kolkata", "starts_at": format.string(from: input.starts_at), - "ends_at": format.string(from: input.ends_at), "status": status, "cancellation_cutoff_at": format.string(from: input.starts_at), "can_cancel": true], at: 0) + "ends_at": format.string(from: input.ends_at), "status": status, "cancellation_cutoff_at": format.string(from: input.starts_at), "can_cancel": true, "confirmation_available": false], at: 0) value["results"] = rows; value["count"] = rows.count; bookingsJSON = try json(value) } func registered(status: String) throws { @@ -110,9 +110,9 @@ actor DemoMemberActionsRepository: MemberActionsRepository { func availability(space: String, start: Date, end: Date) async throws -> Components.Schemas.PublicSpaceAvailability { try check(); return .init(public_token: space, starts_at: start, ends_at: end, timezone: "Asia/Kolkata", opening_intervals: [.init(starts_at: start, ends_at: end)], availability: []) } - func book(space: String, input: Components.Schemas.PublicBookingInput) async throws -> Components.Schemas.PublicBookingResponse { - try check(); let status: Components.Schemas.PublicBookingResponseStatusEnum = scenario == .pending ? .pending : .confirmed - try await store.booked(input, status: status.rawValue); return .init(status: .init(value1: status)) + func book(space: String, input: Components.Schemas.PublicBookingInput) async throws -> MemberBookingCreated { + try check(); let pending = scenario == .pending + try await store.booked(input, status: pending ? "pending" : "confirmed"); return .case1(.init(status: pending ? .pending : .confirmed)) } func cancelBooking(token: String) async throws -> Components.Schemas.MemberBooking { try check(); return try await store.cancelBooking(token) } func register(event: String, input: Components.Schemas.PublicEventRegistrationInput) async throws -> Components.Schemas.PublicEventRegistrationResponse { diff --git a/SpaceWorks/LiveMemberActionsRepository.swift b/SpaceWorks/LiveMemberActionsRepository.swift index 9228ed4..0177582 100644 --- a/SpaceWorks/LiveMemberActionsRepository.swift +++ b/SpaceWorks/LiveMemberActionsRepository.swift @@ -30,7 +30,7 @@ struct LiveMemberActionsRepository: MemberActionsRepository { func availability(space: String, start: Date, end: Date) async throws -> Components.Schemas.PublicSpaceAvailability { try await checked { try await service.bookingAvailability(slug: slug, token: space, start: start, end: end) } } - func book(space: String, input: Components.Schemas.PublicBookingInput) async throws -> Components.Schemas.PublicBookingResponse { + func book(space: String, input: Components.Schemas.PublicBookingInput) async throws -> MemberBookingCreated { try await checked { try await service.createMemberBooking(slug: slug, token: space, input: input) } } func cancelBooking(token: String) async throws -> Components.Schemas.MemberBooking { diff --git a/SpaceWorks/MemberActionsState.swift b/SpaceWorks/MemberActionsState.swift index 1979666..761cbf4 100644 --- a/SpaceWorks/MemberActionsState.swift +++ b/SpaceWorks/MemberActionsState.swift @@ -7,7 +7,7 @@ protocol MemberActionsRepository: Sendable { func submit(items: [Components.Schemas.RequestItemInput], purpose: String, key: String) async throws -> Components.Schemas.RequestSubmitResponse func renew(request: Int) async throws -> Components.Schemas.LoanRenewal func availability(space: String, start: Date, end: Date) async throws -> Components.Schemas.PublicSpaceAvailability - func book(space: String, input: Components.Schemas.PublicBookingInput) async throws -> Components.Schemas.PublicBookingResponse + func book(space: String, input: Components.Schemas.PublicBookingInput) async throws -> MemberBookingCreated func cancelBooking(token: String) async throws -> Components.Schemas.MemberBooking func register(event: String, input: Components.Schemas.PublicEventRegistrationInput) async throws -> Components.Schemas.PublicEventRegistrationResponse func cancelEvent(registration: Int) async throws -> Components.Schemas.MemberRegistrationCancelResponse diff --git a/SpaceWorks/PaymentsState.swift b/SpaceWorks/PaymentsState.swift index 8ce979f..d525d5b 100644 --- a/SpaceWorks/PaymentsState.swift +++ b/SpaceWorks/PaymentsState.swift @@ -153,7 +153,7 @@ final class PaymentsState { let intent = try await repository.intent(paymentID: paymentID) guard matches(attempt) else { return nil } guard canRequest else { throw throttledCheckout } - guard intent.payment_id == paymentID, !intent.client_secret.isEmpty, !intent.publishable_key.isEmpty else { + guard intent.paymentID == paymentID, intent.hasCheckoutParameters else { throw APIError(status: 0, code: "invalid_payment_setup", detail: String(localized: "Payment setup is unavailable. Contact your makerspace.")) } checkout = .presenting(paymentID) diff --git a/SpaceWorks/PaymentsViews.swift b/SpaceWorks/PaymentsViews.swift index afc6ad1..12b40be 100644 --- a/SpaceWorks/PaymentsViews.swift +++ b/SpaceWorks/PaymentsViews.swift @@ -287,10 +287,17 @@ enum PaymentCopy { completion(.unavailable("Checkout is not ready. Try again from payment details.")); return } while let parent = controller.parent { controller = parent } - // Current contract is Stripe-specific. No vendor inference or Razorpay field synthesis. - payments.presentStripe(clientSecret: intent.client_secret, publishableKey: intent.publishable_key, - customerID: intent.customer_id.flatMap { $0 }, ephemeralKey: intent.ephemeral_key.flatMap { $0 }, - returnURL: URL(string: "spaceworks://payments/return"), isDemo: isDemo, from: controller, completion: completion) + // The server-selected vendor decides the SDK; the app never chooses or converts amounts. + switch intent { + case .stripe(let stripe): + payments.presentStripe(clientSecret: stripe.client_secret, publishableKey: stripe.publishable_key, + customerID: stripe.customer_id.flatMap { $0 }, ephemeralKey: stripe.ephemeral_key.flatMap { $0 }, + returnURL: URL(string: "spaceworks://payments/return"), isDemo: isDemo, from: controller, completion: completion) + case .razorpay(let razorpay): + payments.presentRazorpay(keyID: razorpay.key_id, orderID: razorpay.order_id, amountMinor: Int64(razorpay.amount_minor), + currency: razorpay.currency, name: razorpay.name, description: razorpay.description, + isDemo: isDemo, from: controller, completion: completion) + } } } private struct PaymentPresentationAnchor: UIViewControllerRepresentable { diff --git a/SpaceWorks/Platform/NativePayments.swift b/SpaceWorks/Platform/NativePayments.swift index 2aaac22..bff4c96 100644 --- a/SpaceWorks/Platform/NativePayments.swift +++ b/SpaceWorks/Platform/NativePayments.swift @@ -107,8 +107,8 @@ enum NativePaymentInput { #endif } - /// Never call until the authoritative contract supplies server-selected Razorpay SDK parameters. - func presentRazorpay(keyID: String, orderID: String, amountMinor: Int64, currency: String, isDemo: Bool, + /// Server-selected Razorpay Order values. `amountMinor` is already in minor units; never rescale it. + func presentRazorpay(keyID: String, orderID: String, amountMinor: Int64, currency: String, name: String, description: String, isDemo: Bool, from controller: UIViewController, completion: @escaping (NativePaymentOutcome) -> Void) { guard !isDemo else { completion(.unavailable("Demo mode does not open a payment provider.")); return } guard NativePaymentInput.razorpayIsValid(key: keyID, order: orderID, amount: amountMinor, currency: currency) else { @@ -120,7 +120,8 @@ enum NativePaymentInput { razorpayCallbacks = delegate razorpay = Razorpay.RazorpayCheckout.initWithKey(keyID, andDelegateWithData: delegate) var options: [AnyHashable: Any] = ["key": keyID, "order_id": orderID, "amount": amountMinor, - "currency": currency, "name": Brand.appName] + "currency": currency, "name": name.isEmpty ? Brand.appName : name, + "description": description] if let color = accentHex(for: controller.traitCollection) { options["theme"] = ["color": color] } razorpay?.open(options, displayController: controller) #else diff --git a/SpaceWorks/StaffDemo.swift b/SpaceWorks/StaffDemo.swift index 1d4e74d..ff30517 100644 --- a/SpaceWorks/StaffDemo.swift +++ b/SpaceWorks/StaffDemo.swift @@ -207,11 +207,11 @@ actor DemoStaffRepository: StaffRepository { let status = attended ? "attended" : scenario == .waitlisted ? "waitlisted" : "registered" return try decode(DebugStaffFixtures.checkin, changes: ["status": status, "confirmable": status == "registered"]) } - func markAttended(registrationID: Int, body: Components.Schemas.EventAttendanceMark) throws -> Components.Schemas.EventRegistrationAdmin { + func markAttended(registrationID: Int, body: Components.Schemas.EventAttendanceMark) throws -> Components.Schemas.EventAttendanceResult { try checked() if scenario == .attendanceConflict { throw APIError(status: 409, code: "changed", detail: String(localized: "This registration changed.")) } guard registrationID == 1101, !attended, scenario != .waitlisted else { throw APIError(status: 409, code: "changed", detail: String(localized: "This registration changed.")) } - attended = true; return try decode(DebugStaffFixtures.registration, changes: ["status": "attended"]) + attended = true; return .EventRegistrationAdmin(try decode(DebugStaffFixtures.registration, changes: ["status": "attended"])) } func spaces(page: Int) throws -> Components.Schemas.BookableSpaceListResponse { let row: Components.Schemas.BookableSpaceAdmin = try decode(DebugStaffFixtures.space) diff --git a/SpaceWorks/StaffRepository.swift b/SpaceWorks/StaffRepository.swift index 39389b8..e938428 100644 --- a/SpaceWorks/StaffRepository.swift +++ b/SpaceWorks/StaffRepository.swift @@ -23,7 +23,7 @@ protocol StaffRepository: Sendable { func kitPreview(body: Components.Schemas.KitPreviewInput) async throws -> Components.Schemas.KitPreview func events(page: Int) async throws -> Components.Schemas.EventListWithDefaults func resolveCheckIn(eventID: Int, body: Components.Schemas.EventCheckInResolveRequest) async throws -> Components.Schemas.EventCheckInResolveResponse - func markAttended(registrationID: Int, body: Components.Schemas.EventAttendanceMark) async throws -> Components.Schemas.EventRegistrationAdmin + func markAttended(registrationID: Int, body: Components.Schemas.EventAttendanceMark) async throws -> Components.Schemas.EventAttendanceResult func spaces(page: Int) async throws -> Components.Schemas.BookableSpaceListResponse func bookings(spaceID: Int, page: Int) async throws -> Components.Schemas.BookingListResponse func approveBooking(id: Int) async throws -> Components.Schemas.BookingAdmin @@ -128,7 +128,7 @@ struct LiveStaffRepository: StaffRepository { try validate(result.next, path: "/api/v1/admin/makerspaces/\(makerspaceID)/events/", after: page); return result } func resolveCheckIn(eventID: Int, body: Components.Schemas.EventCheckInResolveRequest) async throws -> Components.Schemas.EventCheckInResolveResponse { try await checked { try await service.staffResolveCheckIn(eventID: eventID, body: body) } } - func markAttended(registrationID: Int, body: Components.Schemas.EventAttendanceMark) async throws -> Components.Schemas.EventRegistrationAdmin { try await checked { try await service.staffMarkAttended(registrationID: registrationID, body: body) } } + func markAttended(registrationID: Int, body: Components.Schemas.EventAttendanceMark) async throws -> Components.Schemas.EventAttendanceResult { try await checked { try await service.staffMarkAttended(registrationID: registrationID, body: body) } } func spaces(page: Int) async throws -> Components.Schemas.BookableSpaceListResponse { let result = try await checked { try await service.staffSpaces(makerspaceID: makerspaceID, page: page) } try validate(result.next, path: "/api/v1/admin/makerspaces/\(makerspaceID)/spaces/", after: page); return result diff --git a/SpaceWorksTests/AccountOperationTests.swift b/SpaceWorksTests/AccountOperationTests.swift index ef28a4e..7a35e4b 100644 --- a/SpaceWorksTests/AccountOperationTests.swift +++ b/SpaceWorksTests/AccountOperationTests.swift @@ -20,7 +20,7 @@ final class AccountOperationTests: XCTestCase { var object = try JSONSerialization.jsonObject(with: Data(value.utf8)) as! [String: Any]; object.merge(changes) { _, new in new } return String(decoding: try JSONSerialization.data(withJSONObject: object), as: UTF8.self) } - private var me: String { #"{"id":51,"username":"meera","email":"meera@example.test","display_name":"Meera Nair","phone":"","email_verified":true,"role":"member","is_superuser":false,"must_change_password":false,"makerspaces":[]}"# } + private var me: String { #"{"session_authority":"member","id":51,"username":"meera","email":"meera@example.test","display_name":"Meera Nair","phone":"","email_verified":true,"role":"member","is_superuser":false,"must_change_password":false,"makerspaces":[]}"# } private func archiveBytes() throws -> Data { let object = try JSONSerialization.jsonObject(with: Data(DebugAccountFixtures.archive.utf8)) as! [String: String] return try XCTUnwrap(Data(base64Encoded: object["base64"]!)) diff --git a/SpaceWorksTests/MemberActionsNetworkingTests.swift b/SpaceWorksTests/MemberActionsNetworkingTests.swift index 5e677e1..0ffed90 100644 --- a/SpaceWorksTests/MemberActionsNetworkingTests.swift +++ b/SpaceWorksTests/MemberActionsNetworkingTests.swift @@ -69,7 +69,7 @@ final class MemberActionsNetworkingTests: XCTestCase { let renewal = try await service.renewMemberLoan(id: 7, requestID: 4); XCTAssertEqual(renewal.renewal_count, 2) let availability = try await service.bookingAvailability(slug: "fab-lab-kochi", token: token, start: now, end: now.addingTimeInterval(3600)); XCTAssertNil(availability.availability) let booked = try await service.createMemberBooking(slug: "fab-lab-kochi", token: token, input: .init(starts_at: now, ends_at: now.addingTimeInterval(3600))) - XCTAssertEqual(booked.status.value1, .pending) + XCTAssertTrue(booked.pending) let cancelledBooking = try await service.cancelMemberBooking(slug: "fab-lab-kochi", token: token) XCTAssertEqual(cancelledBooking.public_token, token); XCTAssertEqual(cancelledBooking.status.value1.rawValue, "cancelled"); XCTAssertFalse(cancelledBooking.can_cancel) let registered = try await service.registerMemberEvent(slug: "fab-lab-kochi", token: token, input: .init(ticket_type_id: 1)); XCTAssertEqual(registered.status.value1, .waitlisted) diff --git a/SpaceWorksTests/NativePaymentsTests.swift b/SpaceWorksTests/NativePaymentsTests.swift index 3de6b5f..b65960c 100644 --- a/SpaceWorksTests/NativePaymentsTests.swift +++ b/SpaceWorksTests/NativePaymentsTests.swift @@ -33,7 +33,7 @@ final class NativePaymentsTests: XCTestCase { let payments = NativePayments() var outcomes: [NativePaymentOutcome] = [] payments.presentStripe(clientSecret: "pi_1_secret_1", publishableKey: "pk_test_public", isDemo: true, from: UIViewController()) { outcomes.append($0) } - payments.presentRazorpay(keyID: "rzp_test_public", orderID: "order_1", amountMinor: 500, currency: "INR", isDemo: true, from: UIViewController()) { outcomes.append($0) } + payments.presentRazorpay(keyID: "rzp_test_public", orderID: "order_1", amountMinor: 500, currency: "INR", name: "Sample makerspace", description: "Sample deposit", isDemo: true, from: UIViewController()) { outcomes.append($0) } XCTAssertEqual(outcomes, Array(repeating: .unavailable("Demo mode does not open a payment provider."), count: 2)) XCTAssertFalse(payments.hasInFlightPayment) } diff --git a/SpaceWorksTests/PaymentsTests.swift b/SpaceWorksTests/PaymentsTests.swift index 6720447..aeb372a 100644 --- a/SpaceWorksTests/PaymentsTests.swift +++ b/SpaceWorksTests/PaymentsTests.swift @@ -19,14 +19,18 @@ private enum PaymentFixture { """ } static let documents = #"{"count":0,"next":null,"previous":null,"results":[]}"# + static func razorpayIntent(id: Int = 501) throws -> Components.Schemas.MobilePaymentIntentResponse { + try decode("{\"vendor\":\"razorpay\",\"payment_id\":\(id),\"key_id\":\"rzp_test_fixture\",\"order_id\":\"order_fixture\",\"amount_minor\":25000,\"currency\":\"INR\",\"name\":\"Sample makerspace\",\"description\":\"Sample deposit\"}") + } static func intent(id: Int = 501) throws -> Components.Schemas.MobilePaymentIntentResponse { - try decode("{\"payment_id\":\(id),\"client_secret\":\"pi_fixture_secret_test\",\"publishable_key\":\"pk_test_fixture\"}") + try decode("{\"vendor\":\"stripe\",\"payment_id\":\(id),\"client_secret\":\"pi_fixture_secret_test\",\"publishable_key\":\"pk_test_fixture\"}") } } private actor PaymentTestRepository: PaymentRepository { var rows: [Components.Schemas.MemberPayment] var intentID = 501 + var razorpay = false var failure: APIError? private var offline = false private(set) var listCalls = 0 @@ -37,6 +41,7 @@ private actor PaymentTestRepository: PaymentRepository { init(status: String = "pending", online: Bool = true) throws { rows = [try PaymentFixture.row(status: status, online: online)] } func configure(status: String) throws { rows = [try PaymentFixture.row(status: status)] } func configure(intentID: Int) { self.intentID = intentID } + func configureRazorpay() { razorpay = true } func configure(failure: APIError) { self.failure = failure } func configureOffline() { offline = true } func clearFailure() { failure = nil; offline = false } @@ -51,7 +56,7 @@ private actor PaymentTestRepository: PaymentRepository { return rows } func intent(paymentID: Int) async throws -> Components.Schemas.MobilePaymentIntentResponse { - intentCalls += 1; return try PaymentFixture.intent(id: intentID) + intentCalls += 1; return try razorpay ? PaymentFixture.razorpayIntent(id: intentID) : PaymentFixture.intent(id: intentID) } func documents(page: Int) async throws -> Components.Schemas.DocumentPage { documentCalls += 1; requestedPages.append(page) @@ -221,6 +226,17 @@ private actor PaymentTestRepository: PaymentRepository { XCTAssertNil(wrongPayment) XCTAssertFalse(state.busy) } + func testServerSelectedRazorpayIntentReachesPresentationWithItsExactOrder() async throws { + let repository = try PaymentTestRepository() + await repository.configureRazorpay() + let state = PaymentsState(repository: repository) + await state.refresh() + let prepared = await state.prepareCheckout(paymentID: 501) + guard case .razorpay(let razorpay)? = prepared?.1 else { return XCTFail("The server-selected Razorpay vendor must be preserved") } + XCTAssertEqual(razorpay.order_id, "order_fixture") + XCTAssertEqual(razorpay.amount_minor, 25000, "Minor units are passed through unchanged") + XCTAssertEqual(state.checkout, .presenting(501)) + } func testLogoutClearsPaymentDataResetsSDKAndRejectsLateCallback() async throws { let repository = try PaymentTestRepository() var reset = false @@ -319,12 +335,26 @@ final class PaymentOperationTests: XCTestCase { XCTAssertEqual(request.url?.path, "/api/v1/member/makerspaces/7/payments/501/mobile-intent") XCTAssertEqual(request.httpMethod, "POST") XCTAssertTrue(requestBody(request).isEmpty) - return (200, ["Content-Type": "application/json"], Data(#"{"payment_id":501,"client_secret":"pi_fixture_secret_test","publishable_key":"pk_test_fixture"}"#.utf8)) + return (200, ["Content-Type": "application/json"], Data(#"{"vendor":"stripe","payment_id":501,"client_secret":"pi_fixture_secret_test","publishable_key":"pk_test_fixture"}"#.utf8)) } let rows = try await service.memberPayments(makerspaceID: 7) XCTAssertEqual(rows.first?.id, 501) let intent = try await service.memberPaymentIntent(makerspaceID: 7, paymentID: 501) - XCTAssertEqual(intent.payment_id, 501) + XCTAssertEqual(intent.paymentID, 501) + } + func testRazorpayIntentDecodesTheVendorDiscriminatedGeneratedVariant() async throws { + let service = try await service { request in + self.assertScoped(request) + XCTAssertEqual(request.url?.path, "/api/v1/member/makerspaces/7/payments/501/mobile-intent") + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertTrue(requestBody(request).isEmpty) + return (200, ["Content-Type": "application/json"], Data(#"{"vendor":"razorpay","payment_id":501,"key_id":"rzp_test_fixture","order_id":"order_fixture","amount_minor":25000,"currency":"INR","name":"Sample makerspace","description":"Sample deposit"}"#.utf8)) + } + let intent = try await service.memberPaymentIntent(makerspaceID: 7, paymentID: 501) + guard case .razorpay(let value) = intent else { return XCTFail("Expected the Razorpay variant") } + XCTAssertEqual(value.key_id, "rzp_test_fixture"); XCTAssertEqual(value.currency, "INR") + XCTAssertTrue(intent.hasCheckoutParameters) + XCTAssertTrue(NativePaymentInput.razorpayIsValid(key: value.key_id, order: value.order_id, amount: Int64(value.amount_minor), currency: value.currency)) } func testDocumentListUsesGeneratedPagingAndPDFRetainsRequiredAPIAuthentication() async throws { let service = try await service { request in diff --git a/api/openapi.json b/api/openapi.json index 587703c..c1df20f 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -6,6 +6,138 @@ "description": "Multi-tenant makerspace hardware loan system.\n\nPublic flow: browse inventory, search with `q`, page with `page`, sign in as a member, submit a borrow request, then track it by public token.\n\nAdmin flow: authenticate with JWT, manage makerspaces, inventory, staff, QR labels, bulk imports, request review, issue, and return.\n\nAuthentication: staff/admin endpoints use `Authorization: Bearer `. Public browser endpoints can use `X-Publishable-Key` when public key hardening is enabled. Server API clients send `X-Client-Id`, `X-Timestamp`, `X-Nonce`, and `X-Signature`. Generate a unique, unpredictable `X-Nonce` for every request and sign the byte sequence `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY` with HMAC-SHA256. The nonce uses 1-128 characters from `A-Z`, `a-z`, `0-9`, `.`, `_`, `~`, and `-`. A deployment may temporarily accept the legacy nonce-less signed format while `APICLIENT_REQUIRE_NONCE` is disabled." }, "paths": { + "/.well-known/apple-app-site-association": { + "get": { + "operationId": ".well_known_apple_app_site_association_retrieve", + "tags": [ + "Mobile associations" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "applinks": { + "type": "object", + "properties": { + "apps": { + "type": "array", + "items": { + "type": "string" + } + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "appIDs": { + "type": "array", + "items": { + "type": "string" + } + }, + "components": { + "type": "array", + "items": { + "type": "object", + "properties": { + "/": { + "type": "string" + }, + "exclude": { + "type": "boolean" + } + } + } + } + } + } + } + } + }, + "webcredentials": { + "type": "object", + "properties": { + "apps": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "description": "" + }, + "400": { + "description": "Managed host middleware refuses the host." + }, + "404": { + "description": "Host is not an eligible frontend host." + } + } + } + }, + "/.well-known/assetlinks.json": { + "get": { + "operationId": ".well_known_assetlinks.json_retrieve", + "tags": [ + "Mobile associations" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "relation": { + "type": "array", + "items": { + "type": "string" + } + }, + "target": { + "type": "object", + "properties": { + "namespace": { + "type": "string" + }, + "package_name": { + "type": "string" + }, + "sha256_cert_fingerprints": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "description": "" + }, + "400": { + "description": "Managed host middleware refuses the host." + }, + "404": { + "description": "Host is not an eligible frontend host." + } + } + } + }, "/api/public/{makerspace_slug}/inventory/": { "get": { "operationId": "api_public_inventory_list", @@ -772,131 +904,27 @@ } } }, - "/api/v1/admin/analytics/{report_key}": { + "/api/v1/admin/accessible-booking-spaces/": { "get": { - "operationId": "api_v1_admin_analytics_retrieve", - "summary": "Get aggregate analytics report", + "operationId": "api_v1_admin_accessible_booking_spaces_retrieve", "parameters": [ { "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "grain", - "schema": { - "type": "string", - "enum": [ - "day", - "month", - "week" - ] - } - }, - { - "in": "query", - "name": "limit", + "name": "makerspace_id", "schema": { "type": "integer" } }, { "in": "query", - "name": "months", + "name": "page", "schema": { "type": "integer" - }, - "description": "Retention anniversary in months (1-120); default 3, 6 and 12." - }, - { - "in": "path", - "name": "report_key", - "schema": { - "type": "string", - "enum": [ - "active-loans", - "booking-utilization", - "cash-book", - "certification-coverage", - "communications-health", - "community-engagement", - "damaged-lost", - "damaged-missing", - "event-attendance", - "evidence-compliance", - "fablab-health", - "import-quality", - "inventory-control", - "loan-throughput", - "machine-service", - "machine-usage", - "maintenance-activity", - "member-activity", - "member-retention", - "membership-revenue", - "module-operational-health", - "most-lent", - "payment-cash-book", - "payment-reconciliation", - "printer-service", - "procurement-performance", - "qr-scans", - "recently-added", - "returns", - "revenue-over-time", - "summary", - "taken-items", - "top-borrowers" - ] - }, - "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" - ] } } ], "tags": [ - "Analytics" + "Admin bookings" ], "security": [ { @@ -908,7 +936,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/BookableSpaceListResponse" } } }, @@ -918,61 +946,73 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid report request." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace or report not found." + "description": "" } } } }, - "/api/v1/admin/api-clients/{id}": { + "/api/v1/admin/accessible-events/": { "get": { - "operationId": "api_v1_admin_api_clients_retrieve", - "summary": "Retrieve, update, or delete API client", + "operationId": "api_v1_admin_accessible_events_retrieve", "parameters": [ { - "in": "path", - "name": "id", + "in": "query", + "name": "makerspace_id", "schema": { "type": "integer" - }, - "required": true + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer" + } } ], "tags": [ - "API clients" + "Admin events" ], "security": [ { @@ -984,7 +1024,313 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiClient" + "$ref": "#/components/schemas/EventListResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/admin/analytics/{report_key}": { + "get": { + "operationId": "api_v1_admin_analytics_retrieve", + "summary": "Get aggregate analytics report", + "parameters": [ + { + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month", + "week" + ] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "months", + "schema": { + "type": "integer" + }, + "description": "Retention anniversary in months (1-120); default 3, 6 and 12." + }, + { + "in": "path", + "name": "report_key", + "schema": { + "type": "string", + "enum": [ + "asset-usage", + "audit-activity", + "booking-lead-time", + "booking-occupancy", + "booking-utilization", + "cash-book", + "certification-coverage", + "communications-health", + "community-engagement", + "consumable-pools", + "consumable-usage", + "damaged-lost", + "damaged-missing", + "device-health", + "event-attendance", + "event-checkin-times", + "event-feedback-scores", + "event-funnel", + "event-revenue", + "event-signups", + "evidence-compliance", + "fablab-health", + "import-quality", + "inventory-control", + "inventory-utilisation", + "inventory-watch-demand", + "loan-throughput", + "machine-downtime", + "machine-errors", + "machine-service", + "machine-usage", + "machine-usage-heatmap", + "maintenance-activity", + "member-activity", + "member-retention", + "membership-growth", + "membership-revenue", + "mobile-auth", + "mobile-devices", + "module-operational-health", + "most-lent", + "organization-membership", + "payment-cash-book", + "payment-reconciliation", + "payments-ageing", + "platform-services", + "platform-usage", + "presence-activity", + "printer-jobs", + "printer-service", + "problem-reports", + "procurement-performance", + "qr-scans", + "recently-added", + "request-funnel", + "revenue-over-time", + "service-queue-times", + "stock-health", + "stock-transfers", + "stocktake-variance", + "summary", + "taken-items", + "warranty-expiry" + ] + }, + "required": true + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_id", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "subject_kind", + "schema": { + "type": "string" + }, + "description": "Typed tenant-local subject (event or bookable_space), paired with subject_id. Booking resources require manage_reservations plus tenant VIEW_AUDIT; resource PDF requires tenant MANAGE_BOOKINGS and both dates. Subject filters cannot be scheduled or aggregated." + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" + ] + } + } + ], + "tags": [ + "Analytics" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsReportResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Invalid report request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." + } + } + } + }, + "/api/v1/admin/api-clients/{id}": { + "get": { + "operationId": "api_v1_admin_api_clients_retrieve", + "summary": "Retrieve, update, or delete API client", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "API clients" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiClient" } } }, @@ -1880,10 +2226,10 @@ } } }, - "/api/v1/admin/bookings/{id}/approve/": { - "post": { - "operationId": "api_v1_admin_bookings_approve_create", - "summary": "Approve a pending booking", + "/api/v1/admin/booking-staff-grants/{id}/": { + "patch": { + "operationId": "api_v1_admin_booking_staff_grants_partial_update", + "description": "Requires live tenant MANAGE_BOOKINGS and bookings.staff_grants. Resource grantees cannot delegate. Invitations expire after seven days; sender authority and permission subset are rechecked on redemption.", "parameters": [ { "in": "path", @@ -1897,6 +2243,25 @@ "tags": [ "Admin bookings" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedBookingStaffGrantPermissions" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedBookingStaffGrantPermissions" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedBookingStaffGrantPermissions" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -1907,13 +2272,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookingAdmin" + "$ref": "#/components/schemas/BookingStaffGrant" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -1921,9 +2286,9 @@ } } }, - "description": "Invalid request." + "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -1931,9 +2296,9 @@ } } }, - "description": "Authentication required." + "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -1941,9 +2306,9 @@ } } }, - "description": "Permission denied." + "description": "" }, - "404": { + "409": { "content": { "application/json": { "schema": { @@ -1951,9 +2316,9 @@ } } }, - "description": "Not found." + "description": "" }, - "409": { + "423": { "content": { "application/json": { "schema": { @@ -1961,15 +2326,46 @@ } } }, - "description": "Workflow conflict." + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Field validation, module or feature disabled." } } } }, - "/api/v1/admin/bookings/{id}/cancel/": { + "/api/v1/admin/booking-staff-grants/{id}/revoke/": { "post": { - "operationId": "api_v1_admin_bookings_cancel_create", - "summary": "Cancel a booking", + "operationId": "api_v1_admin_booking_staff_grants_revoke_create", + "description": "Requires live tenant MANAGE_BOOKINGS and bookings.staff_grants. Resource grantees cannot delegate. Invitations expire after seven days; sender authority and permission subset are rechecked on redemption.", "parameters": [ { "in": "path", @@ -1993,13 +2389,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookingAdmin" + "$ref": "#/components/schemas/BookingStaffGrant" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -2007,9 +2403,9 @@ } } }, - "description": "Invalid request." + "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -2017,9 +2413,9 @@ } } }, - "description": "Authentication required." + "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -2027,9 +2423,9 @@ } } }, - "description": "Permission denied." + "description": "" }, - "404": { + "409": { "content": { "application/json": { "schema": { @@ -2037,9 +2433,9 @@ } } }, - "description": "Not found." + "description": "" }, - "409": { + "423": { "content": { "application/json": { "schema": { @@ -2047,15 +2443,46 @@ } } }, - "description": "Workflow conflict." + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Field validation, module or feature disabled." } } } }, - "/api/v1/admin/bookings/{id}/complete/": { + "/api/v1/admin/booking-staff-invites/{id}/revoke/": { "post": { - "operationId": "api_v1_admin_bookings_complete_create", - "summary": "Complete a booking", + "operationId": "api_v1_admin_booking_staff_invites_revoke_create", + "description": "Requires live tenant MANAGE_BOOKINGS and bookings.staff_grants. Resource grantees cannot delegate. Invitations expire after seven days; sender authority and permission subset are rechecked on redemption.", "parameters": [ { "in": "path", @@ -2079,13 +2506,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookingAdmin" + "$ref": "#/components/schemas/BookingStaffGrantInvite" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -2093,7 +2520,124 @@ } } }, - "description": "Invalid request." + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Field validation, module or feature disabled." + } + } + } + }, + "/api/v1/admin/booking-staff-invites/redeem/": { + "post": { + "operationId": "api_v1_admin_booking_staff_invites_redeem_create", + "description": "Authenticated active account; single-use invite with verified email binding, current sender authority, selected tenant/origin, expiry and revocation checks. Rate limited to 10/min/account; private/no-store.", + "tags": [ + "Bookings" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingStaffGrantRedeem" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/BookingStaffGrantRedeem" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/BookingStaffGrantRedeem" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingStaffGrant" + } + } + }, + "description": "" }, "401": { "content": { @@ -2103,7 +2647,7 @@ } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { @@ -2113,7 +2657,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -2123,7 +2667,7 @@ } } }, - "description": "Not found." + "description": "" }, "409": { "content": { @@ -2133,15 +2677,56 @@ } } }, - "description": "Workflow conflict." + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Field validation, module or feature disabled." } } } }, - "/api/v1/admin/bookings/{id}/no-show/": { + "/api/v1/admin/bookings/{id}/approve/": { "post": { - "operationId": "api_v1_admin_bookings_no_show_create", - "summary": "Mark a booking as no-show", + "operationId": "api_v1_admin_bookings_approve_create", + "summary": "Approve a pending booking", "parameters": [ { "in": "path", @@ -2224,10 +2809,10 @@ } } }, - "/api/v1/admin/bookings/{id}/reject/": { + "/api/v1/admin/bookings/{id}/cancel/": { "post": { - "operationId": "api_v1_admin_bookings_reject_create", - "summary": "Reject a pending booking", + "operationId": "api_v1_admin_bookings_cancel_create", + "summary": "Cancel a booking", "parameters": [ { "in": "path", @@ -2310,10 +2895,10 @@ } } }, - "/api/v1/admin/categories/{id}": { - "get": { - "operationId": "api_v1_admin_categories_retrieve", - "summary": "Retrieve, update, or delete inventory category", + "/api/v1/admin/bookings/{id}/complete/": { + "post": { + "operationId": "api_v1_admin_bookings_complete_create", + "summary": "Complete a booking", "parameters": [ { "in": "path", @@ -2325,7 +2910,7 @@ } ], "tags": [ - "Admin inventory" + "Admin bookings" ], "security": [ { @@ -2337,99 +2922,70 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CategoryAdmin" + "$ref": "#/components/schemas/BookingAdmin" } } }, "description": "" - } - } - }, - "patch": { - "operationId": "api_v1_admin_categories_partial_update", - "summary": "Retrieve, update, or delete inventory category", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin inventory" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedCategoryAdmin" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedCategoryAdmin" + "description": "Invalid request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedCategoryAdmin" + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } - } - } - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + }, + "description": "Permission denied." + }, + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CategoryAdmin" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" - } - } - }, - "delete": { - "operationId": "api_v1_admin_categories_destroy", - "summary": "Retrieve, update, or delete inventory category", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true - } - ], - "tags": [ - "Admin inventory" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "204": { - "description": "No response body" + "description": "Workflow conflict." } } } }, - "/api/v1/admin/certification-grants/{id}/renew": { - "post": { - "operationId": "api_v1_admin_certification_grants_renew_create", - "summary": "Renew training with a new grant", + "/api/v1/admin/bookings/{id}/details/": { + "patch": { + "operationId": "api_v1_admin_bookings_details_partial_update", + "description": "Requires resource manage_reservations and bookings.staff_tools. Unknown fields are refused; answers use the current schema.", + "summary": "Edit private reservation note or answers", "parameters": [ { "in": "path", @@ -2441,23 +2997,23 @@ } ], "tags": [ - "Admin machines" + "Admin bookings" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CertificationGrantRevoke" + "$ref": "#/components/schemas/PatchedBookingDetailsInput" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/CertificationGrantRevoke" + "$ref": "#/components/schemas/PatchedBookingDetailsInput" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/CertificationGrantRevoke" + "$ref": "#/components/schemas/PatchedBookingDetailsInput" } } } @@ -2468,38 +3024,83 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CertificationGrant" + "$ref": "#/components/schemas/BookingAdmin" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." }, - "423": { - "description": "Makerspace writes are frozen for tenant migration." + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Too many requests." } } } }, - "/api/v1/admin/certification-grants/{id}/revoke": { + "/api/v1/admin/bookings/{id}/no-show/": { "post": { - "operationId": "api_v1_admin_certification_grants_revoke_create", - "summary": "Revoke a certification grant", + "operationId": "api_v1_admin_bookings_no_show_create", + "summary": "Mark a booking as no-show", "parameters": [ { "in": "path", @@ -2511,27 +3112,8 @@ } ], "tags": [ - "Admin machines" + "Admin bookings" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CertificationGrantRevoke" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/CertificationGrantRevoke" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/CertificationGrantRevoke" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -2542,153 +3124,69 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CertificationGrant" + "$ref": "#/components/schemas/BookingAdmin" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "423": { - "description": "Makerspace frozen for tenant migration." - } - } - } - }, - "/api/v1/admin/certification-types/{id}": { - "patch": { - "operationId": "api_v1_admin_certification_types_partial_update", - "summary": "Update a certification type", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedCertificationTypeUpdate" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedCertificationTypeUpdate" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedCertificationTypeUpdate" - } - } - } - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CertificationType" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" - }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "description": "Invalid request." }, "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "423": { - "description": "Makerspace frozen for tenant migration." - } - } - }, - "delete": { - "operationId": "api_v1_admin_certification_types_destroy", - "summary": "Deactivate a certification type", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CertificationType" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" - }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." + "description": "Authentication required." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." }, - "423": { - "description": "Makerspace frozen for tenant migration." + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." } } } }, - "/api/v1/admin/certification-types/{id}/grants": { - "get": { - "operationId": "api_v1_admin_certification_types_grants_retrieve", - "summary": "List grants for a certification type", + "/api/v1/admin/bookings/{id}/reject/": { + "post": { + "operationId": "api_v1_admin_bookings_reject_create", + "summary": "Reject a pending booking", "parameters": [ { "in": "path", @@ -2697,18 +3195,10 @@ "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer" - }, - "description": "Page number (24 rows per page)." } ], "tags": [ - "Admin machines" + "Admin bookings" ], "security": [ { @@ -2720,156 +3210,70 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CertificationGrantPage" + "$ref": "#/components/schemas/BookingAdmin" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - } - } - }, - "post": { - "operationId": "api_v1_admin_certification_types_grants_create", - "summary": "Grant a certification to a membership", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CertificationGrantCreate" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/CertificationGrantCreate" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/CertificationGrantCreate" - } - } + "description": "Invalid request." }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "201": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CertificationGrant" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" - }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." + "description": "Authentication required." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "423": { - "description": "Makerspace frozen for tenant migration." - } - } - } - }, - "/api/v1/admin/certification-types/{id}/members": { - "get": { - "operationId": "api_v1_admin_certification_types_members_retrieve", - "summary": "List active members eligible for training grants", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true + "description": "Not found." }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer" - }, - "description": "Page number (24 rows per page)." - } - ], - "tags": [ - "Admin machines" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CertificationMemberPage" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" - }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "description": "Workflow conflict." } } } }, - "/api/v1/admin/containers/{id}": { - "get": { - "operationId": "api_v1_admin_containers_retrieve", - "summary": "Retrieve container", + "/api/v1/admin/bookings/{id}/restore/": { + "post": { + "operationId": "api_v1_admin_bookings_restore_create", + "description": "Requires resource manage_reservations and bookings.staff_tools. Within five minutes, restore the exact pending/confirmed state under current admission. Any historical or newly required charge refuses undo.", + "summary": "Undo a recent cancellation without money movement", "parameters": [ { "in": "path", @@ -2881,7 +3285,7 @@ } ], "tags": [ - "Containers" + "Admin bookings" ], "security": [ { @@ -2893,7 +3297,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Box" + "$ref": "#/components/schemas/BookingAdmin" } } }, @@ -2907,7 +3311,7 @@ } } }, - "description": "" + "description": "Invalid request." }, "401": { "content": { @@ -2917,7 +3321,7 @@ } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { @@ -2927,7 +3331,7 @@ } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { @@ -2937,13 +3341,36 @@ } } }, - "description": "" + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Too many requests." } } - }, - "patch": { - "operationId": "api_v1_admin_containers_partial_update", - "summary": "Update container", + } + }, + "/api/v1/admin/bookings/{id}/time/": { + "post": { + "operationId": "api_v1_admin_bookings_time_create", + "description": "Requires resource manage_reservations and bookings.staff_tools. Both old and proposed windows must be beyond the cutoff; ordinary admission rules apply. Identity, status and flat charge are preserved.", + "summary": "Move a pending or confirmed reservation", "parameters": [ { "in": "path", @@ -2955,26 +3382,27 @@ } ], "tags": [ - "Containers" + "Admin bookings" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedBox" + "$ref": "#/components/schemas/BookingTimeInput" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedBox" + "$ref": "#/components/schemas/BookingTimeInput" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedBox" + "$ref": "#/components/schemas/BookingTimeInput" } } - } + }, + "required": true }, "security": [ { @@ -2986,7 +3414,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Box" + "$ref": "#/components/schemas/BookingAdmin" } } }, @@ -3000,7 +3428,7 @@ } } }, - "description": "" + "description": "Invalid request." }, "401": { "content": { @@ -3010,7 +3438,7 @@ } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { @@ -3020,7 +3448,7 @@ } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { @@ -3030,15 +3458,35 @@ } } }, - "description": "" + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Too many requests." } } } }, - "/api/v1/admin/containers/{id}/contents": { + "/api/v1/admin/categories/{id}": { "get": { - "operationId": "api_v1_admin_containers_contents_retrieve", - "summary": "Get container contents", + "operationId": "api_v1_admin_categories_retrieve", + "summary": "Retrieve, update, or delete inventory category", "parameters": [ { "in": "path", @@ -3050,7 +3498,7 @@ } ], "tags": [ - "Containers" + "Admin inventory" ], "security": [ { @@ -3062,19 +3510,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ContainerContents" + "$ref": "#/components/schemas/CategoryAdmin" } } }, "description": "" } } - } - }, - "/api/v1/admin/containers/{id}/history": { - "get": { - "operationId": "api_v1_admin_containers_history_retrieve", - "summary": "Get container scan history", + }, + "patch": { + "operationId": "api_v1_admin_categories_partial_update", + "summary": "Retrieve, update, or delete inventory category", "parameters": [ { "in": "path", @@ -3086,8 +3532,27 @@ } ], "tags": [ - "Containers" + "Admin inventory" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedCategoryAdmin" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedCategoryAdmin" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedCategoryAdmin" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -3098,19 +3563,46 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ContainerHistory" + "$ref": "#/components/schemas/CategoryAdmin" } } }, "description": "" } } + }, + "delete": { + "operationId": "api_v1_admin_categories_destroy", + "summary": "Retrieve, update, or delete inventory category", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin inventory" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + } + } } }, - "/api/v1/admin/containers/{id}/move": { + "/api/v1/admin/certification-grants/{id}/renew": { "post": { - "operationId": "api_v1_admin_containers_move_create", - "summary": "Move or update container", + "operationId": "api_v1_admin_certification_grants_renew_create", + "summary": "Renew training with a new grant", "parameters": [ { "in": "path", @@ -3122,23 +3614,23 @@ } ], "tags": [ - "Containers" + "Admin machines" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ContainerMove" + "$ref": "#/components/schemas/CertificationGrantRevoke" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ContainerMove" + "$ref": "#/components/schemas/CertificationGrantRevoke" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ContainerMove" + "$ref": "#/components/schemas/CertificationGrantRevoke" } } } @@ -3149,91 +3641,66 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Box" + "$ref": "#/components/schemas/CertificationGrant" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." } } } }, - "/api/v1/admin/device-printer-bindings/{id}": { - "patch": { - "operationId": "api_v1_admin_device_printer_bindings_partial_update", + "/api/v1/admin/certification-grants/{id}/revoke": { + "post": { + "operationId": "api_v1_admin_certification_grants_revoke_create", + "summary": "Revoke a certification grant", "parameters": [ { "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Devices" + "Admin machines" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedBindingUpdate" + "$ref": "#/components/schemas/CertificationGrantRevoke" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedBindingUpdate" + "$ref": "#/components/schemas/CertificationGrantRevoke" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedBindingUpdate" + "$ref": "#/components/schemas/CertificationGrantRevoke" } } } @@ -3248,99 +3715,114 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Binding" + "$ref": "#/components/schemas/CertificationGrant" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Invalid payload or adapter module disabled." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Authentication/signature/replay refused." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Authority or device revoked." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "423": { + "description": "Makerspace frozen for tenant migration." + } + } + } + }, + "/api/v1/admin/certification-types/{id}": { + "patch": { + "operationId": "api_v1_admin_certification_types_partial_update", + "summary": "Update a certification type", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedCertificationTypeUpdate" } }, - "description": "Target outside scope or absent." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedCertificationTypeUpdate" } }, - "description": "Conflicting command/report." - }, - "410": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedCertificationTypeUpdate" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/CertificationType" } } }, - "description": "Delivery lease expired." + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." }, "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Tenant frozen for migration." + "description": "Makerspace frozen for tenant migration." } } }, "delete": { - "operationId": "api_v1_admin_device_printer_bindings_destroy", + "operationId": "api_v1_admin_certification_types_destroy", + "summary": "Deactivate a certification type", "parameters": [ { "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Devices" + "Admin machines" ], "security": [ { @@ -3348,99 +3830,58 @@ } ], "responses": { - "204": { - "description": "No response body" - }, - "400": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/CertificationType" } } }, - "description": "Invalid payload or adapter module disabled." + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Authentication/signature/replay refused." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Authority or device revoked." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Target outside scope or absent." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Conflicting command/report." - }, - "410": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Delivery lease expired." + "description": "Not found, not visible to this actor, or the surface is not enabled here." }, "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Tenant frozen for migration." + "description": "Makerspace frozen for tenant migration." } } } }, - "/api/v1/admin/devices/{id}": { + "/api/v1/admin/certification-types/{id}/grants": { "get": { - "operationId": "api_v1_admin_devices_retrieve", - "summary": "Retrieve a device", + "operationId": "api_v1_admin_certification_types_grants_retrieve", + "summary": "List grants for a certification type", "parameters": [ { "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + }, + "description": "Page number (24 rows per page)." } ], "tags": [ - "Devices" + "Admin machines" ], "security": [ { @@ -3452,99 +3893,61 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Device" + "$ref": "#/components/schemas/CertificationGrantPage" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Invalid request or module disabled." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Authentication required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Permission denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Not found." - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Tenant frozen for migration." + "description": "Not found, not visible to this actor, or the surface is not enabled here." } } }, - "patch": { - "operationId": "api_v1_admin_devices_partial_update", - "summary": "Rename a device or change its offline threshold", + "post": { + "operationId": "api_v1_admin_certification_types_grants_create", + "summary": "Grant a certification to a membership", "parameters": [ { "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Devices" + "Admin machines" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedDeviceUpdate" + "$ref": "#/components/schemas/CertificationGrantCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedDeviceUpdate" + "$ref": "#/components/schemas/CertificationGrantCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedDeviceUpdate" + "$ref": "#/components/schemas/CertificationGrantCreate" } } - } + }, + "required": true }, "security": [ { @@ -3552,86 +3955,58 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Device" + "$ref": "#/components/schemas/CertificationGrant" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Invalid request or module disabled." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Authentication required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Permission denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Not found." + "description": "Not found, not visible to this actor, or the surface is not enabled here." }, "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Tenant frozen for migration." + "description": "Makerspace frozen for tenant migration." } } } }, - "/api/v1/admin/devices/{id}/enrollments": { - "post": { - "operationId": "api_v1_admin_devices_enrollments_create", - "summary": "Issue a new enrollment (enroll, rotate or reactivate by device status)", + "/api/v1/admin/certification-types/{id}/members": { + "get": { + "operationId": "api_v1_admin_certification_types_members_retrieve", + "summary": "List active members eligible for training grants", "parameters": [ { "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + }, + "description": "Page number (24 rows per page)." } ], "tags": [ - "Devices" + "Admin machines" ], "security": [ { @@ -3639,86 +4014,47 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EnrollmentGrant" + "$ref": "#/components/schemas/CertificationMemberPage" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Invalid request or module disabled." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Authentication required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Permission denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Not found." - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Tenant frozen for migration." + "description": "Not found, not visible to this actor, or the surface is not enabled here." } } } }, - "/api/v1/admin/devices/{id}/revoke": { - "post": { - "operationId": "api_v1_admin_devices_revoke_create", - "summary": "Revoke a device immediately", + "/api/v1/admin/containers/{id}": { + "get": { + "operationId": "api_v1_admin_containers_retrieve", + "summary": "Retrieve container", "parameters": [ { "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Devices" + "Containers" ], "security": [ { @@ -3730,7 +4066,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Device" + "$ref": "#/components/schemas/Box" } } }, @@ -3740,59 +4076,47 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request or module disabled." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Not found." - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant frozen for migration." + "description": "" } } - } - }, - "/api/v1/admin/direct-loans/{id}/return": { - "post": { - "operationId": "api_v1_admin_direct_loans_return_create", - "summary": "Return direct handout loan", + }, + "patch": { + "operationId": "api_v1_admin_containers_partial_update", + "summary": "Update container", "parameters": [ { "in": "path", @@ -3804,27 +4128,26 @@ } ], "tags": [ - "Admin requests" + "Containers" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DirectLoanReturn" + "$ref": "#/components/schemas/PatchedBox" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/DirectLoanReturn" + "$ref": "#/components/schemas/PatchedBox" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/DirectLoanReturn" + "$ref": "#/components/schemas/PatchedBox" } } - }, - "required": true + } }, "security": [ { @@ -3836,7 +4159,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DirectLoan" + "$ref": "#/components/schemas/Box" } } }, @@ -3850,7 +4173,7 @@ } } }, - "description": "Invalid request." + "description": "" }, "401": { "content": { @@ -3860,7 +4183,7 @@ } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { @@ -3870,7 +4193,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -3880,34 +4203,15 @@ } } }, - "description": "Not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Workflow conflict." - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Service unavailable." + "description": "" } } } }, - "/api/v1/admin/event-certificates/{id}/download/": { + "/api/v1/admin/containers/{id}/contents": { "get": { - "operationId": "api_v1_admin_event_certificates_download_retrieve", + "operationId": "api_v1_admin_containers_contents_retrieve", + "summary": "Get container contents", "parameters": [ { "in": "path", @@ -3919,7 +4223,7 @@ } ], "tags": [ - "Admin events" + "Containers" ], "security": [ { @@ -3931,78 +4235,55 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CertificateDownload" + "$ref": "#/components/schemas/ContainerContents" } } }, "description": "" - }, - "410": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Certificate revoked." - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Certificate storage unavailable." - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid request." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Event management is required." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } + } + } + } + }, + "/api/v1/admin/containers/{id}/history": { + "get": { + "operationId": "api_v1_admin_containers_history_retrieve", + "summary": "Get container scan history", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" }, - "description": "Event resource not found." - }, - "409": { + "required": true + } + ], + "tags": [ + "Containers" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ContainerHistory" } } }, - "description": "Event state conflict." + "description": "" } } } }, - "/api/v1/admin/event-certificates/{id}/reissue/": { + "/api/v1/admin/containers/{id}/move": { "post": { - "operationId": "api_v1_admin_event_certificates_reissue_create", + "operationId": "api_v1_admin_containers_move_create", + "summary": "Move or update container", "parameters": [ { "in": "path", @@ -4014,19 +4295,38 @@ } ], "tags": [ - "Admin events" + "Containers" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContainerMove" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ContainerMove" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ContainerMove" + } + } + } + }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CertificateSummary" + "$ref": "#/components/schemas/Box" } } }, @@ -4040,9 +4340,9 @@ } } }, - "description": "Invalid request." + "description": "" }, - "403": { + "401": { "content": { "application/json": { "schema": { @@ -4050,9 +4350,9 @@ } } }, - "description": "Event management is required." + "description": "" }, - "404": { + "403": { "content": { "application/json": { "schema": { @@ -4060,9 +4360,9 @@ } } }, - "description": "Event resource not found." + "description": "" }, - "409": { + "404": { "content": { "application/json": { "schema": { @@ -4070,46 +4370,46 @@ } } }, - "description": "Event state conflict." + "description": "" } } } }, - "/api/v1/admin/event-certificates/{id}/revoke/": { - "post": { - "operationId": "api_v1_admin_event_certificates_revoke_create", + "/api/v1/admin/device-printer-bindings/{id}": { + "patch": { + "operationId": "api_v1_admin_device_printer_bindings_partial_update", "parameters": [ { "in": "path", "name": "id", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Admin events" + "Devices" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CertificateRevoke" + "$ref": "#/components/schemas/PatchedBindingUpdate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/CertificateRevoke" + "$ref": "#/components/schemas/PatchedBindingUpdate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/CertificateRevoke" + "$ref": "#/components/schemas/PatchedBindingUpdate" } } - }, - "required": true + } }, "security": [ { @@ -4121,7 +4421,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CertificateSummary" + "$ref": "#/components/schemas/Binding" } } }, @@ -4131,61 +4431,89 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Invalid request." + "description": "Invalid payload or adapter module disabled." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Authentication/signature/replay refused." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Event management is required." + "description": "Authority or device revoked." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Event resource not found." + "description": "Target outside scope or absent." }, "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Event state conflict." + "description": "Conflicting command/report." + }, + "410": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Delivery lease expired." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Tenant frozen for migration." } } - } - }, - "/api/v1/admin/event-collaborations/{id}/remove/": { - "post": { - "operationId": "api_v1_admin_event_collaborations_remove_create", - "summary": "Remove an event collaborator", + }, + "delete": { + "operationId": "api_v1_admin_device_printer_bindings_destroy", "parameters": [ { "in": "path", "name": "id", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Admin events" + "Devices" ], "security": [ { @@ -4200,136 +4528,92 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Invalid collaboration request." + "description": "Invalid payload or adapter module disabled." }, - "403": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Event management access denied." + "description": "Authentication/signature/replay refused." }, - "404": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Event collaboration not found." - } - } - } - }, - "/api/v1/admin/event-collaborations/{id}/respond/": { - "post": { - "operationId": "api_v1_admin_event_collaborations_respond_create", - "summary": "Accept or decline an event collaboration", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin events" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EventCollaborationRespond" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/EventCollaborationRespond" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/EventCollaborationRespond" - } - } + "description": "Authority or device revoked." }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventCollaborator" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Target outside scope or absent." }, - "400": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Invalid collaboration request." + "description": "Conflicting command/report." }, - "403": { + "410": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Event management access denied." + "description": "Delivery lease expired." }, - "404": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Event collaboration not found." + "description": "Tenant frozen for migration." } } } }, - "/api/v1/admin/event-invites/{id}/revoke/": { - "post": { - "operationId": "api_v1_admin_event_invites_revoke_create", + "/api/v1/admin/devices/{id}": { + "get": { + "operationId": "api_v1_admin_devices_retrieve", + "summary": "Retrieve a device", "parameters": [ { "in": "path", "name": "id", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Admin events" + "Devices" ], "security": [ { @@ -4341,7 +4625,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Invite" + "$ref": "#/components/schemas/Device" } } }, @@ -4351,102 +4635,90 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Invalid field values, disabled events module or disabled feature." + "description": "Invalid request or module disabled." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Not found." }, "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Tenant frozen for migration." } } - } - }, - "/api/v1/admin/event-promos/{id}/revoke/": { - "post": { - "operationId": "api_v1_admin_event_promos_revoke_create", + }, + "patch": { + "operationId": "api_v1_admin_devices_partial_update", + "summary": "Rename a device or change its offline threshold", "parameters": [ { "in": "path", "name": "id", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Admin events" + "Devices" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedDeviceUpdate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedDeviceUpdate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedDeviceUpdate" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -4457,7 +4729,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Promo" + "$ref": "#/components/schemas/Device" } } }, @@ -4467,102 +4739,72 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Invalid field values, disabled events module or disabled feature." + "description": "Invalid request or module disabled." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Not found." }, "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Tenant frozen for migration." } } } }, - "/api/v1/admin/event-registrations/{id}/approve/": { + "/api/v1/admin/devices/{id}/enrollments": { "post": { - "operationId": "api_v1_admin_event_registrations_approve_create", - "summary": "Approve a pending event registration", + "operationId": "api_v1_admin_devices_enrollments_create", + "summary": "Issue a new enrollment (enroll, rotate or reactivate by device status)", "parameters": [ { "in": "path", "name": "id", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Admin events" + "Devices" ], "security": [ { @@ -4570,11 +4812,11 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventRegistrationAdmin" + "$ref": "#/components/schemas/EnrollmentGrant" } } }, @@ -4584,72 +4826,72 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Unexpected request body or events module disabled." + "description": "Invalid request or module disabled." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Authentication is required." + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Event management is required." + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Registration not found." + "description": "Not found." }, - "409": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Event state or capacity conflict." + "description": "Tenant frozen for migration." } } } }, - "/api/v1/admin/event-registrations/{id}/cancel/": { + "/api/v1/admin/devices/{id}/revoke": { "post": { - "operationId": "api_v1_admin_event_registrations_cancel_create", - "summary": "Cancel an event registration and release its pending charge", + "operationId": "api_v1_admin_devices_revoke_create", + "summary": "Revoke a device immediately", "parameters": [ { "in": "path", "name": "id", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Admin events" + "Devices" ], "security": [ { @@ -4661,7 +4903,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventRegistrationAdmin" + "$ref": "#/components/schemas/Device" } } }, @@ -4671,59 +4913,59 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Unexpected request body or events module disabled." + "description": "Invalid request or module disabled." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Authentication is required." + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Event management is required." + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Registration not found." + "description": "Not found." }, - "409": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Event state or capacity conflict." + "description": "Tenant frozen for migration." } } } }, - "/api/v1/admin/event-registrations/{id}/correct-attendance/": { + "/api/v1/admin/direct-loans/{id}/return": { "post": { - "operationId": "api_v1_admin_event_registrations_correct_attendance_create", + "operationId": "api_v1_admin_direct_loans_return_create", + "summary": "Return direct handout loan", "parameters": [ { "in": "path", @@ -4735,8 +4977,28 @@ } ], "tags": [ - "Admin events" + "Admin requests" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DirectLoanReturn" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/DirectLoanReturn" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/DirectLoanReturn" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -4747,7 +5009,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AttendanceCorrectionResponse" + "$ref": "#/components/schemas/DirectLoan" } } }, @@ -4763,6 +5025,16 @@ }, "description": "Invalid request." }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, "403": { "content": { "application/json": { @@ -4771,7 +5043,7 @@ } } }, - "description": "Event management is required." + "description": "Permission denied." }, "404": { "content": { @@ -4781,7 +5053,7 @@ } } }, - "description": "Event resource not found." + "description": "Not found." }, "409": { "content": { @@ -4791,15 +5063,25 @@ } } }, - "description": "Event state conflict." + "description": "Workflow conflict." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Service unavailable." } } } }, - "/api/v1/admin/event-registrations/{id}/mark-attended/": { - "post": { - "operationId": "api_v1_admin_event_registrations_mark_attended_create", - "summary": "Mark an event registration attended", + "/api/v1/admin/event-certificates/{id}/download/": { + "get": { + "operationId": "api_v1_admin_event_certificates_download_retrieve", + "description": "Private audited certificate download; configurable renders are bounded to 30/hour per account.", "parameters": [ { "in": "path", @@ -4813,25 +5095,6 @@ "tags": [ "Admin events" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EventAttendanceMark" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/EventAttendanceMark" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/EventAttendanceMark" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -4842,24 +5105,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventRegistrationAdmin" + "$ref": "#/components/schemas/CertificateDownload" } } }, "description": "" }, - "400": { + "429": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected request body or events module disabled." + "description": "" }, - "401": { + "410": { "content": { "application/json": { "schema": { @@ -4867,7 +5129,27 @@ } } }, - "description": "Authentication is required." + "description": "Certificate revoked." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Certificate storage unavailable." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." }, "403": { "content": { @@ -4887,7 +5169,7 @@ } } }, - "description": "Registration not found." + "description": "Event resource not found." }, "409": { "content": { @@ -4897,15 +5179,14 @@ } } }, - "description": "Event state or capacity conflict." + "description": "Event state conflict." } } } }, - "/api/v1/admin/event-registrations/{id}/promote/": { + "/api/v1/admin/event-certificates/{id}/reissue/": { "post": { - "operationId": "api_v1_admin_event_registrations_promote_create", - "summary": "Manually promote an approved waitlisted registration", + "operationId": "api_v1_admin_event_certificates_reissue_create", "parameters": [ { "in": "path", @@ -4925,28 +5206,17 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventRegistrationAdmin" + "$ref": "#/components/schemas/CertificateSummary" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Unexpected request body or events module disabled." - }, - "401": { "content": { "application/json": { "schema": { @@ -4954,7 +5224,7 @@ } } }, - "description": "Authentication is required." + "description": "Invalid request." }, "403": { "content": { @@ -4974,7 +5244,7 @@ } } }, - "description": "Registration not found." + "description": "Event resource not found." }, "409": { "content": { @@ -4984,15 +5254,14 @@ } } }, - "description": "Event state or capacity conflict." + "description": "Event state conflict." } } } }, - "/api/v1/admin/event-registrations/{id}/reject/": { + "/api/v1/admin/event-certificates/{id}/revoke/": { "post": { - "operationId": "api_v1_admin_event_registrations_reject_create", - "summary": "Reject a pending or waitlisted event registration", + "operationId": "api_v1_admin_event_certificates_revoke_create", "parameters": [ { "in": "path", @@ -5006,6 +5275,26 @@ "tags": [ "Admin events" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificateRevoke" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/CertificateRevoke" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/CertificateRevoke" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -5016,24 +5305,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventRegistrationAdmin" + "$ref": "#/components/schemas/CertificateSummary" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Unexpected request body or events module disabled." - }, - "401": { "content": { "application/json": { "schema": { @@ -5041,7 +5319,7 @@ } } }, - "description": "Authentication is required." + "description": "Invalid request." }, "403": { "content": { @@ -5061,7 +5339,7 @@ } } }, - "description": "Registration not found." + "description": "Event resource not found." }, "409": { "content": { @@ -5071,14 +5349,15 @@ } } }, - "description": "Event state or capacity conflict." + "description": "Event state conflict." } } } }, - "/api/v1/admin/event-series-collaborations/{id}/remove/": { + "/api/v1/admin/event-collaborations/{id}/remove/": { "post": { - "operationId": "api_v1_admin_event_series_collaborations_remove_create", + "operationId": "api_v1_admin_event_collaborations_remove_create", + "summary": "Remove an event collaborator", "parameters": [ { "in": "path", @@ -5090,7 +5369,7 @@ } ], "tags": [ - "Admin event series" + "Admin events" ], "security": [ { @@ -5111,7 +5390,7 @@ }, "description": "Invalid collaboration request." }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -5119,9 +5398,9 @@ } } }, - "description": "Authentication required." + "description": "Event management access denied." }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -5129,9 +5408,65 @@ } } }, - "description": "Event management access denied." + "description": "Event collaboration not found." + } + } + } + }, + "/api/v1/admin/event-collaborations/{id}/respond/": { + "post": { + "operationId": "api_v1_admin_event_collaborations_respond_create", + "summary": "Accept or decline an event collaboration", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin events" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventCollaborationRespond" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/EventCollaborationRespond" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/EventCollaborationRespond" + } + } }, - "404": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventCollaborator" + } + } + }, + "description": "" + }, + "400": { "content": { "application/json": { "schema": { @@ -5139,9 +5474,9 @@ } } }, - "description": "Series collaboration not found." + "description": "Invalid collaboration request." }, - "409": { + "403": { "content": { "application/json": { "schema": { @@ -5149,9 +5484,9 @@ } } }, - "description": "Collaboration state conflict." + "description": "Event management access denied." }, - "429": { + "404": { "content": { "application/json": { "schema": { @@ -5159,14 +5494,14 @@ } } }, - "description": "Rate limit exceeded." + "description": "Event collaboration not found." } } } }, - "/api/v1/admin/event-series-collaborations/{id}/respond/": { + "/api/v1/admin/event-invites/{id}/revoke/": { "post": { - "operationId": "api_v1_admin_event_series_collaborations_respond_create", + "operationId": "api_v1_admin_event_invites_revoke_create", "parameters": [ { "in": "path", @@ -5178,28 +5513,8 @@ } ], "tags": [ - "Admin event series" + "Admin events" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SeriesCollaborationRespond" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/SeriesCollaborationRespond" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/SeriesCollaborationRespond" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -5210,7 +5525,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SeriesCollaborator" + "$ref": "#/components/schemas/Invite" } } }, @@ -5220,11 +5535,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": {} } } }, - "description": "Invalid collaboration request." + "description": "Invalid field values, disabled events module or disabled feature." }, "401": { "content": { @@ -5234,7 +5550,7 @@ } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { @@ -5244,7 +5560,7 @@ } } }, - "description": "Event management access denied." + "description": "" }, "404": { "content": { @@ -5254,7 +5570,7 @@ } } }, - "description": "Series collaboration not found." + "description": "" }, "409": { "content": { @@ -5264,7 +5580,17 @@ } } }, - "description": "Collaboration state conflict." + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "429": { "content": { @@ -5274,14 +5600,24 @@ } } }, - "description": "Rate limit exceeded." + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/event-series/{id}/": { - "get": { - "operationId": "api_v1_admin_event_series_retrieve", + "/api/v1/admin/event-promos/{id}/revoke/": { + "post": { + "operationId": "api_v1_admin_event_promos_revoke_create", "parameters": [ { "in": "path", @@ -5293,7 +5629,7 @@ } ], "tags": [ - "Admin event series" + "Admin events" ], "security": [ { @@ -5305,7 +5641,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventSeriesDetail" + "$ref": "#/components/schemas/Promo" } } }, @@ -5315,11 +5651,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": {} } } }, - "description": "Invalid recurring event series." + "description": "Invalid field values, disabled events module or disabled feature." }, "401": { "content": { @@ -5329,7 +5666,7 @@ } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { @@ -5339,7 +5676,7 @@ } } }, - "description": "Event management access denied." + "description": "" }, "404": { "content": { @@ -5349,7 +5686,7 @@ } } }, - "description": "Event series not found." + "description": "" }, "409": { "content": { @@ -5359,7 +5696,17 @@ } } }, - "description": "Series state conflict." + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "429": { "content": { @@ -5369,12 +5716,25 @@ } } }, - "description": "Rate limit exceeded." + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } - }, + } + }, + "/api/v1/admin/event-registrations/{id}/answers/": { "patch": { - "operationId": "api_v1_admin_event_series_partial_update", + "operationId": "api_v1_admin_event_registrations_answers_partial_update", + "summary": "Edit validated custom registration answers", "parameters": [ { "in": "path", @@ -5386,23 +5746,23 @@ } ], "tags": [ - "Admin event series" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedEventSeriesWrite" + "$ref": "#/components/schemas/PatchedRegistrationAnswers" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedEventSeriesWrite" + "$ref": "#/components/schemas/PatchedRegistrationAnswers" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedEventSeriesWrite" + "$ref": "#/components/schemas/PatchedRegistrationAnswers" } } } @@ -5417,7 +5777,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventSeriesMutationResponse" + "$ref": "#/components/schemas/EventRegistrationAdmin" } } }, @@ -5427,11 +5787,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": {} } } }, - "description": "Invalid recurring event series." + "description": "Unexpected request body or events module disabled." }, "401": { "content": { @@ -5441,7 +5802,7 @@ } } }, - "description": "Authentication required." + "description": "Authentication is required." }, "403": { "content": { @@ -5451,7 +5812,7 @@ } } }, - "description": "Event management access denied." + "description": "Event management is required." }, "404": { "content": { @@ -5461,7 +5822,7 @@ } } }, - "description": "Event series not found." + "description": "Registration not found." }, "409": { "content": { @@ -5471,24 +5832,15 @@ } } }, - "description": "Series state conflict." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Rate limit exceeded." + "description": "Event state or capacity conflict." } } } }, - "/api/v1/admin/event-series/{id}/cancel/": { + "/api/v1/admin/event-registrations/{id}/approve/": { "post": { - "operationId": "api_v1_admin_event_series_cancel_create", + "operationId": "api_v1_admin_event_registrations_approve_create", + "summary": "Approve a pending event registration", "parameters": [ { "in": "path", @@ -5500,7 +5852,7 @@ } ], "tags": [ - "Admin event series" + "Admin events" ], "security": [ { @@ -5512,7 +5864,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventSeriesMutationResponse" + "$ref": "#/components/schemas/EventRegistrationAdmin" } } }, @@ -5522,11 +5874,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": {} } } }, - "description": "Invalid recurring event series." + "description": "Unexpected request body or events module disabled." }, "401": { "content": { @@ -5536,7 +5889,7 @@ } } }, - "description": "Authentication required." + "description": "Authentication is required." }, "403": { "content": { @@ -5546,7 +5899,7 @@ } } }, - "description": "Event management access denied." + "description": "Event management is required." }, "404": { "content": { @@ -5556,7 +5909,7 @@ } } }, - "description": "Event series not found." + "description": "Registration not found." }, "409": { "content": { @@ -5566,24 +5919,15 @@ } } }, - "description": "Series state conflict." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Rate limit exceeded." + "description": "Event state or capacity conflict." } } } }, - "/api/v1/admin/event-series/{id}/collaborators/": { - "get": { - "operationId": "api_v1_admin_event_series_collaborators_list", + "/api/v1/admin/event-registrations/{id}/cancel/": { + "post": { + "operationId": "api_v1_admin_event_registrations_cancel_create", + "summary": "Cancel an event registration and release its pending charge", "parameters": [ { "in": "path", @@ -5595,7 +5939,7 @@ } ], "tags": [ - "Admin event series" + "Admin events" ], "security": [ { @@ -5607,10 +5951,7 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SeriesCollaborator" - } + "$ref": "#/components/schemas/EventRegistrationAdmin" } } }, @@ -5620,11 +5961,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": {} } } }, - "description": "Invalid collaboration request." + "description": "Unexpected request body or events module disabled." }, "401": { "content": { @@ -5634,7 +5976,7 @@ } } }, - "description": "Authentication required." + "description": "Authentication is required." }, "403": { "content": { @@ -5644,7 +5986,7 @@ } } }, - "description": "Event management access denied." + "description": "Event management is required." }, "404": { "content": { @@ -5654,7 +5996,7 @@ } } }, - "description": "Series collaboration not found." + "description": "Registration not found." }, "409": { "content": { @@ -5664,22 +6006,14 @@ } } }, - "description": "Collaboration state conflict." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Rate limit exceeded." + "description": "Event state or capacity conflict." } } - }, - "put": { - "operationId": "api_v1_admin_event_series_collaborators_update", + } + }, + "/api/v1/admin/event-registrations/{id}/correct-attendance/": { + "post": { + "operationId": "api_v1_admin_event_registrations_correct_attendance_create", "parameters": [ { "in": "path", @@ -5691,28 +6025,8 @@ } ], "tags": [ - "Admin event series" + "Admin events" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SeriesCollaboratorReplace" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/SeriesCollaboratorReplace" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/SeriesCollaboratorReplace" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -5723,10 +6037,7 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SeriesCollaborator" - } + "$ref": "#/components/schemas/AttendanceCorrectionResponse" } } }, @@ -5740,17 +6051,7 @@ } } }, - "description": "Invalid collaboration request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Authentication required." + "description": "Invalid request." }, "403": { "content": { @@ -5760,7 +6061,7 @@ } } }, - "description": "Event management access denied." + "description": "Event management is required." }, "404": { "content": { @@ -5770,7 +6071,7 @@ } } }, - "description": "Series collaboration not found." + "description": "Event resource not found." }, "409": { "content": { @@ -5780,24 +6081,15 @@ } } }, - "description": "Collaboration state conflict." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Rate limit exceeded." + "description": "Event state conflict." } } } }, - "/api/v1/admin/event-series/{id}/complete/": { + "/api/v1/admin/event-registrations/{id}/mark-attended/": { "post": { - "operationId": "api_v1_admin_event_series_complete_create", + "operationId": "api_v1_admin_event_registrations_mark_attended_create", + "summary": "Mark an event registration attended", "parameters": [ { "in": "path", @@ -5809,8 +6101,27 @@ } ], "tags": [ - "Admin event series" + "Admin events" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventAttendanceMark" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/EventAttendanceMark" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/EventAttendanceMark" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -5821,7 +6132,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventSeriesMutationResponse" + "$ref": "#/components/schemas/EventAttendanceResult" } } }, @@ -5831,11 +6142,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": {} } } }, - "description": "Invalid recurring event series." + "description": "Unexpected request body or events module disabled." }, "401": { "content": { @@ -5845,7 +6157,7 @@ } } }, - "description": "Authentication required." + "description": "Authentication is required." }, "403": { "content": { @@ -5855,7 +6167,7 @@ } } }, - "description": "Event management access denied." + "description": "Event management is required." }, "404": { "content": { @@ -5865,7 +6177,7 @@ } } }, - "description": "Event series not found." + "description": "Registration not found." }, "409": { "content": { @@ -5875,24 +6187,15 @@ } } }, - "description": "Series state conflict." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Rate limit exceeded." + "description": "Event state or capacity conflict." } } } }, - "/api/v1/admin/event-series/{id}/extend/": { + "/api/v1/admin/event-registrations/{id}/promote/": { "post": { - "operationId": "api_v1_admin_event_series_extend_create", + "operationId": "api_v1_admin_event_registrations_promote_create", + "summary": "Manually promote an approved waitlisted registration", "parameters": [ { "in": "path", @@ -5904,7 +6207,7 @@ } ], "tags": [ - "Admin event series" + "Admin events" ], "security": [ { @@ -5916,7 +6219,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventSeriesMutationResponse" + "$ref": "#/components/schemas/EventRegistrationAdmin" } } }, @@ -5926,11 +6229,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": {} } } }, - "description": "Invalid recurring event series." + "description": "Unexpected request body or events module disabled." }, "401": { "content": { @@ -5940,7 +6244,7 @@ } } }, - "description": "Authentication required." + "description": "Authentication is required." }, "403": { "content": { @@ -5950,7 +6254,7 @@ } } }, - "description": "Event management access denied." + "description": "Event management is required." }, "404": { "content": { @@ -5960,7 +6264,7 @@ } } }, - "description": "Event series not found." + "description": "Registration not found." }, "409": { "content": { @@ -5970,25 +6274,15 @@ } } }, - "description": "Series state conflict." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Rate limit exceeded." + "description": "Event state or capacity conflict." } } } }, - "/api/v1/admin/event-series/{id}/image": { + "/api/v1/admin/event-registrations/{id}/reject/": { "post": { - "operationId": "api_v1_admin_event_series_image_create", - "summary": "Create a series image upload URL", + "operationId": "api_v1_admin_event_registrations_reject_create", + "summary": "Reject a pending or waitlisted event registration", "parameters": [ { "in": "path", @@ -6000,139 +6294,82 @@ } ], "tags": [ - "Admin event series" + "Admin events" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicImageUploadResponse" + "$ref": "#/components/schemas/EventRegistrationAdmin" } } }, "description": "" }, "400": { - "description": "Invalid image upload request." + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Unexpected request body or events module disabled." }, "401": { - "description": "Authentication required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required." }, "403": { - "description": "Event management access is required." - }, - "404": { - "description": "Event series not found." - }, - "429": { - "description": "Rate limit exceeded." - }, - "503": { - "description": "Public image storage is unavailable." - } - } - }, - "put": { - "operationId": "api_v1_admin_event_series_image_update", - "summary": "Attach an uploaded series image", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin event series" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" + "description": "Event management is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" - } - } + "description": "Registration not found." }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventSeriesDetail" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" - }, - "400": { - "description": "Invalid image upload request." - }, - "401": { - "description": "Authentication required." - }, - "403": { - "description": "Event management access is required." - }, - "404": { - "description": "Event series not found." - }, - "429": { - "description": "Rate limit exceeded." - }, - "503": { - "description": "Public image storage is unavailable." + "description": "Event state or capacity conflict." } } - }, - "delete": { - "operationId": "api_v1_admin_event_series_image_destroy", - "summary": "Clear a series image", + } + }, + "/api/v1/admin/event-registrations/{id}/restore/": { + "post": { + "operationId": "api_v1_admin_event_registrations_restore_create", + "summary": "Exactly restore a cancellation within five minutes", "parameters": [ { "in": "path", @@ -6144,7 +6381,7 @@ } ], "tags": [ - "Admin event series" + "Admin events" ], "security": [ { @@ -6156,36 +6393,69 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventSeriesDetail" + "$ref": "#/components/schemas/EventRegistrationAdmin" } } }, "description": "" }, "400": { - "description": "Invalid image upload request." + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Unexpected request body or events module disabled." }, "401": { - "description": "Authentication required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required." }, "403": { - "description": "Event management access is required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management is required." }, "404": { - "description": "Event series not found." - }, - "429": { - "description": "Rate limit exceeded." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Registration not found." }, - "503": { - "description": "Public image storage is unavailable." + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event state or capacity conflict." } } } }, - "/api/v1/admin/event-series/{id}/occurrences/": { - "get": { - "operationId": "api_v1_admin_event_series_occurrences_retrieve", + "/api/v1/admin/event-series-collaborations/{id}/remove/": { + "post": { + "operationId": "api_v1_admin_event_series_collaborations_remove_create", "parameters": [ { "in": "path", @@ -6205,15 +6475,8 @@ } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EventListResponse" - } - } - }, - "description": "" + "204": { + "description": "No response body" }, "400": { "content": { @@ -6223,7 +6486,7 @@ } } }, - "description": "Invalid recurring event series." + "description": "Invalid collaboration request." }, "401": { "content": { @@ -6253,7 +6516,7 @@ } } }, - "description": "Event series not found." + "description": "Series collaboration not found." }, "409": { "content": { @@ -6263,7 +6526,7 @@ } } }, - "description": "Series state conflict." + "description": "Collaboration state conflict." }, "429": { "content": { @@ -6278,9 +6541,9 @@ } } }, - "/api/v1/admin/event-series/{id}/publish/": { + "/api/v1/admin/event-series-collaborations/{id}/respond/": { "post": { - "operationId": "api_v1_admin_event_series_publish_create", + "operationId": "api_v1_admin_event_series_collaborations_respond_create", "parameters": [ { "in": "path", @@ -6294,6 +6557,26 @@ "tags": [ "Admin event series" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SeriesCollaborationRespond" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/SeriesCollaborationRespond" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/SeriesCollaborationRespond" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -6304,7 +6587,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventSeriesMutationResponse" + "$ref": "#/components/schemas/SeriesCollaborator" } } }, @@ -6318,7 +6601,7 @@ } } }, - "description": "Invalid recurring event series." + "description": "Invalid collaboration request." }, "401": { "content": { @@ -6348,7 +6631,7 @@ } } }, - "description": "Event series not found." + "description": "Series collaboration not found." }, "409": { "content": { @@ -6358,7 +6641,7 @@ } } }, - "description": "Series state conflict." + "description": "Collaboration state conflict." }, "429": { "content": { @@ -6373,10 +6656,9 @@ } } }, - "/api/v1/admin/events/{id}/": { + "/api/v1/admin/event-series/{id}/": { "get": { - "operationId": "api_v1_admin_events_retrieve", - "summary": "Retrieve an event", + "operationId": "api_v1_admin_event_series_retrieve", "parameters": [ { "in": "path", @@ -6388,7 +6670,7 @@ } ], "tags": [ - "Admin events" + "Admin event series" ], "security": [ { @@ -6400,7 +6682,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventAdmin" + "$ref": "#/components/schemas/EventSeriesDetail" } } }, @@ -6414,7 +6696,7 @@ } } }, - "description": "" + "description": "Invalid recurring event series." }, "401": { "content": { @@ -6424,7 +6706,7 @@ } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { @@ -6434,7 +6716,7 @@ } } }, - "description": "" + "description": "Event management access denied." }, "404": { "content": { @@ -6444,13 +6726,32 @@ } } }, - "description": "" + "description": "Event series not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Series state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." } } }, "patch": { - "operationId": "api_v1_admin_events_partial_update", - "summary": "Update an event", + "operationId": "api_v1_admin_event_series_partial_update", "parameters": [ { "in": "path", @@ -6462,23 +6763,23 @@ } ], "tags": [ - "Admin events" + "Admin event series" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedEventWrite" + "$ref": "#/components/schemas/PatchedEventSeriesWrite" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedEventWrite" + "$ref": "#/components/schemas/PatchedEventSeriesWrite" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedEventWrite" + "$ref": "#/components/schemas/PatchedEventSeriesWrite" } } } @@ -6493,7 +6794,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventAdmin" + "$ref": "#/components/schemas/EventSeriesMutationResponse" } } }, @@ -6503,14 +6804,13 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid event details." + "description": "Invalid recurring event series." }, - "409": { + "401": { "content": { "application/json": { "schema": { @@ -6518,9 +6818,9 @@ } } }, - "description": "Event state or capacity conflict." + "description": "Authentication required." }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -6528,9 +6828,9 @@ } } }, - "description": "" + "description": "Event management access denied." }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -6538,9 +6838,9 @@ } } }, - "description": "" + "description": "Event series not found." }, - "404": { + "409": { "content": { "application/json": { "schema": { @@ -6548,14 +6848,24 @@ } } }, - "description": "" + "description": "Series state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." } } } }, - "/api/v1/admin/events/{id}/badge-template/": { - "get": { - "operationId": "api_v1_admin_events_badge_template_retrieve", + "/api/v1/admin/event-series/{id}/cancel/": { + "post": { + "operationId": "api_v1_admin_event_series_cancel_create", "parameters": [ { "in": "path", @@ -6567,7 +6877,7 @@ } ], "tags": [ - "Admin events" + "Admin event series" ], "security": [ { @@ -6579,12 +6889,32 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadgeTemplate" + "$ref": "#/components/schemas/EventSeriesMutationResponse" } } }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid recurring event series." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, "403": { "content": { "application/json": { @@ -6593,7 +6923,7 @@ } } }, - "description": "" + "description": "Event management access denied." }, "404": { "content": { @@ -6603,12 +6933,34 @@ } } }, - "description": "" - } - } - }, - "put": { - "operationId": "api_v1_admin_events_badge_template_update", + "description": "Event series not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Series state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/admin/event-series/{id}/collaborators/": { + "get": { + "operationId": "api_v1_admin_event_series_collaborators_list", "parameters": [ { "in": "path", @@ -6620,27 +6972,8 @@ } ], "tags": [ - "Admin events" + "Admin event series" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadgeTemplate" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/BadgeTemplate" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/BadgeTemplate" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -6651,7 +6984,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadgeTemplate" + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesCollaborator" + } } } }, @@ -6661,12 +6997,21 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid event details." + "description": "Invalid collaboration request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." }, "403": { "content": { @@ -6676,7 +7021,7 @@ } } }, - "description": "" + "description": "Event management access denied." }, "404": { "content": { @@ -6686,7 +7031,7 @@ } } }, - "description": "" + "description": "Series collaboration not found." }, "409": { "content": { @@ -6696,14 +7041,22 @@ } } }, - "description": "Event state or capacity conflict." + "description": "Collaboration state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." } } - } - }, - "/api/v1/admin/events/{id}/badges.pdf": { - "post": { - "operationId": "api_v1_admin_events_badges.pdf_create", + }, + "put": { + "operationId": "api_v1_admin_event_series_collaborators_update", "parameters": [ { "in": "path", @@ -6715,23 +7068,23 @@ } ], "tags": [ - "Admin events" + "Admin event series" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadgePdfRequest" + "$ref": "#/components/schemas/SeriesCollaboratorReplace" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/BadgePdfRequest" + "$ref": "#/components/schemas/SeriesCollaboratorReplace" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/BadgePdfRequest" + "$ref": "#/components/schemas/SeriesCollaboratorReplace" } } }, @@ -6745,25 +7098,36 @@ "responses": { "200": { "content": { - "application/pdf": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesCollaborator" + } } } }, - "description": "Print-ready badge PDF." + "description": "" }, "400": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid event details." + "description": "Invalid collaboration request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." }, "403": { "content": { @@ -6773,7 +7137,7 @@ } } }, - "description": "" + "description": "Event management access denied." }, "404": { "content": { @@ -6783,7 +7147,7 @@ } } }, - "description": "" + "description": "Series collaboration not found." }, "409": { "content": { @@ -6793,15 +7157,24 @@ } } }, - "description": "Event state or capacity conflict." + "description": "Collaboration state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." } } } }, - "/api/v1/admin/events/{id}/cancel/": { + "/api/v1/admin/event-series/{id}/complete/": { "post": { - "operationId": "api_v1_admin_events_cancel_create", - "summary": "Cancel an event", + "operationId": "api_v1_admin_event_series_complete_create", "parameters": [ { "in": "path", @@ -6813,7 +7186,7 @@ } ], "tags": [ - "Admin events" + "Admin event series" ], "security": [ { @@ -6825,7 +7198,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventAdmin" + "$ref": "#/components/schemas/EventSeriesMutationResponse" } } }, @@ -6839,7 +7212,7 @@ } } }, - "description": "" + "description": "Invalid recurring event series." }, "401": { "content": { @@ -6849,7 +7222,7 @@ } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { @@ -6859,7 +7232,7 @@ } } }, - "description": "" + "description": "Event management access denied." }, "404": { "content": { @@ -6869,7 +7242,7 @@ } } }, - "description": "" + "description": "Event series not found." }, "409": { "content": { @@ -6879,15 +7252,24 @@ } } }, - "description": "Event state or capacity conflict." + "description": "Series state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." } } } }, - "/api/v1/admin/events/{id}/check-in/offline-roster/": { - "get": { - "operationId": "api_v1_admin_events_check_in_offline_roster_retrieve", - "summary": "Download a minimal expiring offline check-in roster", + "/api/v1/admin/event-series/{id}/extend/": { + "post": { + "operationId": "api_v1_admin_event_series_extend_create", "parameters": [ { "in": "path", @@ -6899,7 +7281,7 @@ } ], "tags": [ - "Admin events" + "Admin event series" ], "security": [ { @@ -6911,7 +7293,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OfflineRosterResponse" + "$ref": "#/components/schemas/EventSeriesMutationResponse" } } }, @@ -6925,7 +7307,7 @@ } } }, - "description": "Feature disabled or invalid batch." + "description": "Invalid recurring event series." }, "401": { "content": { @@ -6935,7 +7317,7 @@ } } }, - "description": "Authentication or lease failed." + "description": "Authentication required." }, "403": { "content": { @@ -6945,7 +7327,7 @@ } } }, - "description": "Event authority changed." + "description": "Event management access denied." }, "404": { "content": { @@ -6955,7 +7337,7 @@ } } }, - "description": "Event not found." + "description": "Event series not found." }, "409": { "content": { @@ -6965,27 +7347,7 @@ } } }, - "description": "Roster window closed." - }, - "410": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Synchronization deadline passed." - }, - "413": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Roster exceeds the offline limit." + "description": "Series state conflict." }, "429": { "content": { @@ -6995,15 +7357,15 @@ } } }, - "description": "Request rate limit exceeded." + "description": "Rate limit exceeded." } } } }, - "/api/v1/admin/events/{id}/check-in/offline-sync/": { + "/api/v1/admin/event-series/{id}/image": { "post": { - "operationId": "api_v1_admin_events_check_in_offline_sync_create", - "summary": "Synchronize queued offline event check-ins", + "operationId": "api_v1_admin_event_series_image_create", + "summary": "Create a series image upload URL", "parameters": [ { "in": "path", @@ -7015,23 +7377,23 @@ } ], "tags": [ - "Admin events" + "Admin event series" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OfflineCheckInSyncRequest" + "$ref": "#/components/schemas/PublicImageUploadRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/OfflineCheckInSyncRequest" + "$ref": "#/components/schemas/PublicImageUploadRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/OfflineCheckInSyncRequest" + "$ref": "#/components/schemas/PublicImageUploadRequest" } } }, @@ -7043,103 +7405,39 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OfflineCheckInSyncResponse" + "$ref": "#/components/schemas/PublicImageUploadResponse" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Feature disabled or invalid batch." + "description": "Invalid image upload request." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Authentication or lease failed." + "description": "Authentication required." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Event authority changed." + "description": "Event management access is required." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Event not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Roster window closed." - }, - "410": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Synchronization deadline passed." - }, - "413": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Roster exceeds the offline limit." + "description": "Event series not found." }, "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Request rate limit exceeded." + "description": "Rate limit exceeded." + }, + "503": { + "description": "Public image storage is unavailable." } } - } - }, - "/api/v1/admin/events/{id}/check-in/resolve/": { - "post": { - "operationId": "api_v1_admin_events_check_in_resolve_create", - "summary": "Resolve an event check-in token", + }, + "put": { + "operationId": "api_v1_admin_event_series_image_update", + "summary": "Attach an uploaded series image", "parameters": [ { "in": "path", @@ -7151,23 +7449,23 @@ } ], "tags": [ - "Admin events" + "Admin event series" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventCheckInResolveRequest" + "$ref": "#/components/schemas/PublicImageAttachRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/EventCheckInResolveRequest" + "$ref": "#/components/schemas/PublicImageAttachRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/EventCheckInResolveRequest" + "$ref": "#/components/schemas/PublicImageAttachRequest" } } }, @@ -7183,49 +7481,88 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventCheckInResolveResponse" + "$ref": "#/components/schemas/EventSeriesDetail" } } }, "description": "" }, + "400": { + "description": "Invalid image upload request." + }, + "401": { + "description": "Authentication required." + }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Event access denied." + "description": "Event management access is required." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Registration not found." + "description": "Event series not found." }, "429": { + "description": "Rate limit exceeded." + }, + "503": { + "description": "Public image storage is unavailable." + } + } + }, + "delete": { + "operationId": "api_v1_admin_event_series_image_destroy", + "summary": "Clear a series image", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin event series" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/EventSeriesDetail" } } }, - "description": "Request rate limit exceeded." + "description": "" + }, + "400": { + "description": "Invalid image upload request." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "description": "Event management access is required." + }, + "404": { + "description": "Event series not found." + }, + "429": { + "description": "Rate limit exceeded." + }, + "503": { + "description": "Public image storage is unavailable." } } } }, - "/api/v1/admin/events/{id}/check-in/station/": { + "/api/v1/admin/event-series/{id}/occurrences/": { "get": { - "operationId": "api_v1_admin_events_check_in_station_retrieve", - "summary": "Read venue-station configuration without revealing its PIN", + "operationId": "api_v1_admin_event_series_occurrences_retrieve", "parameters": [ { "in": "path", @@ -7237,7 +7574,7 @@ } ], "tags": [ - "Admin events" + "Admin event series" ], "security": [ { @@ -7249,7 +7586,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StationStatus" + "$ref": "#/components/schemas/EventListResponse" } } }, @@ -7263,9 +7600,9 @@ } } }, - "description": "Feature disabled or invalid request." + "description": "Invalid recurring event series." }, - "403": { + "401": { "content": { "application/json": { "schema": { @@ -7273,9 +7610,9 @@ } } }, - "description": "Event access or step-up denied." + "description": "Authentication required." }, - "404": { + "403": { "content": { "application/json": { "schema": { @@ -7283,9 +7620,9 @@ } } }, - "description": "Event not found." + "description": "Event management access denied." }, - "409": { + "404": { "content": { "application/json": { "schema": { @@ -7293,9 +7630,9 @@ } } }, - "description": "Rotate the PIN instead of revealing." + "description": "Event series not found." }, - "429": { + "409": { "content": { "application/json": { "schema": { @@ -7303,9 +7640,9 @@ } } }, - "description": "Request rate limit exceeded." + "description": "Series state conflict." }, - "503": { + "429": { "content": { "application/json": { "schema": { @@ -7313,13 +7650,14 @@ } } }, - "description": "Credential secrets are unavailable." + "description": "Rate limit exceeded." } } - }, - "delete": { - "operationId": "api_v1_admin_events_check_in_station_destroy", - "summary": "Disable a venue check-in station", + } + }, + "/api/v1/admin/event-series/{id}/publish/": { + "post": { + "operationId": "api_v1_admin_event_series_publish_create", "parameters": [ { "in": "path", @@ -7331,7 +7669,7 @@ } ], "tags": [ - "Admin events" + "Admin event series" ], "security": [ { @@ -7343,7 +7681,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StationStatus" + "$ref": "#/components/schemas/EventSeriesMutationResponse" } } }, @@ -7357,9 +7695,9 @@ } } }, - "description": "Feature disabled or invalid request." + "description": "Invalid recurring event series." }, - "403": { + "401": { "content": { "application/json": { "schema": { @@ -7367,9 +7705,9 @@ } } }, - "description": "Event access or step-up denied." + "description": "Authentication required." }, - "404": { + "403": { "content": { "application/json": { "schema": { @@ -7377,9 +7715,9 @@ } } }, - "description": "Event not found." + "description": "Event management access denied." }, - "409": { + "404": { "content": { "application/json": { "schema": { @@ -7387,9 +7725,9 @@ } } }, - "description": "Rotate the PIN instead of revealing." + "description": "Event series not found." }, - "429": { + "409": { "content": { "application/json": { "schema": { @@ -7397,9 +7735,9 @@ } } }, - "description": "Request rate limit exceeded." + "description": "Series state conflict." }, - "503": { + "429": { "content": { "application/json": { "schema": { @@ -7407,15 +7745,14 @@ } } }, - "description": "Credential secrets are unavailable." + "description": "Rate limit exceeded." } } } }, - "/api/v1/admin/events/{id}/check-in/station/reveal/": { - "post": { - "operationId": "api_v1_admin_events_check_in_station_reveal_create", - "summary": "Reveal the current station PIN after password step-up", + "/api/v1/admin/event-series/{id}/staff-grants/": { + "get": { + "operationId": "api_v1_admin_event_series_staff_grants_retrieve", "parameters": [ { "in": "path", @@ -7424,31 +7761,18 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } } ], "tags": [ "Admin events" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StationReveal" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/StationReveal" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/StationReveal" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -7459,7 +7783,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StationRevealResponse" + "$ref": "#/components/schemas/StaffGrantList" } } }, @@ -7473,9 +7797,9 @@ } } }, - "description": "Feature disabled or invalid request." + "description": "" }, - "403": { + "401": { "content": { "application/json": { "schema": { @@ -7483,9 +7807,9 @@ } } }, - "description": "Event access or step-up denied." + "description": "" }, - "404": { + "403": { "content": { "application/json": { "schema": { @@ -7493,7 +7817,17 @@ } } }, - "description": "Event not found." + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "409": { "content": { @@ -7503,7 +7837,17 @@ } } }, - "description": "Rotate the PIN instead of revealing." + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "429": { "content": { @@ -7513,7 +7857,7 @@ } } }, - "description": "Request rate limit exceeded." + "description": "" }, "503": { "content": { @@ -7523,15 +7867,14 @@ } } }, - "description": "Credential secrets are unavailable." + "description": "" } } } }, - "/api/v1/admin/events/{id}/check-in/station/rotate/": { - "post": { - "operationId": "api_v1_admin_events_check_in_station_rotate_create", - "summary": "Create or rotate an event-scoped eight-digit station PIN", + "/api/v1/admin/event-series/{id}/staff-invites/": { + "get": { + "operationId": "api_v1_admin_event_series_staff_invites_retrieve", "parameters": [ { "in": "path", @@ -7540,6 +7883,13 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } } ], "tags": [ @@ -7555,7 +7905,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StationRotation" + "$ref": "#/components/schemas/StaffGrantInviteList" } } }, @@ -7569,7 +7919,17 @@ } } }, - "description": "Feature disabled or invalid request." + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { "content": { @@ -7579,7 +7939,7 @@ } } }, - "description": "Event access or step-up denied." + "description": "" }, "404": { "content": { @@ -7589,7 +7949,7 @@ } } }, - "description": "Event not found." + "description": "" }, "409": { "content": { @@ -7599,7 +7959,17 @@ } } }, - "description": "Rotate the PIN instead of revealing." + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "429": { "content": { @@ -7609,7 +7979,7 @@ } } }, - "description": "Request rate limit exceeded." + "description": "" }, "503": { "content": { @@ -7619,15 +7989,12 @@ } } }, - "description": "Credential secrets are unavailable." + "description": "" } } - } - }, - "/api/v1/admin/events/{id}/collaborators/": { - "get": { - "operationId": "api_v1_admin_events_collaborators_list", - "summary": "List an event's collaborators", + }, + "post": { + "operationId": "api_v1_admin_event_series_staff_invites_create", "parameters": [ { "in": "path", @@ -7641,20 +8008,37 @@ "tags": [ "Admin events" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StaffGrantInviteInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/StaffGrantInviteInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/StaffGrantInviteInput" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EventCollaborator" - } + "$ref": "#/components/schemas/StaffGrantInviteCreated" } } }, @@ -7668,9 +8052,9 @@ } } }, - "description": "Invalid collaboration request." + "description": "" }, - "403": { + "401": { "content": { "application/json": { "schema": { @@ -7678,9 +8062,9 @@ } } }, - "description": "Event management access denied." + "description": "" }, - "404": { + "403": { "content": { "application/json": { "schema": { @@ -7688,66 +8072,29 @@ } } }, - "description": "Event collaboration not found." - } - } - }, - "put": { - "operationId": "api_v1_admin_events_collaborators_update", - "summary": "Replace an event's collaborators", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin events" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EventCollaboratorReplace" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/EventCollaboratorReplace" + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/EventCollaboratorReplace" - } - } + "description": "" }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "409": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EventCollaborator" - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "400": { + "423": { "content": { "application/json": { "schema": { @@ -7755,9 +8102,9 @@ } } }, - "description": "Invalid collaboration request." + "description": "" }, - "403": { + "429": { "content": { "application/json": { "schema": { @@ -7765,9 +8112,9 @@ } } }, - "description": "Event management access denied." + "description": "" }, - "404": { + "503": { "content": { "application/json": { "schema": { @@ -7775,15 +8122,14 @@ } } }, - "description": "Event collaboration not found." + "description": "" } } } }, - "/api/v1/admin/events/{id}/complete/": { - "post": { - "operationId": "api_v1_admin_events_complete_create", - "summary": "Complete an event", + "/api/v1/admin/event-series/{id}/website/": { + "get": { + "operationId": "api_v1_admin_event_series_website_retrieve", "parameters": [ { "in": "path", @@ -7795,7 +8141,7 @@ } ], "tags": [ - "Admin events" + "Event website" ], "security": [ { @@ -7807,7 +8153,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventAdmin" + "$ref": "#/components/schemas/Website" } } }, @@ -7821,7 +8167,7 @@ } } }, - "description": "" + "description": "Invalid content, stale revision, disabled feature or terminal event." }, "401": { "content": { @@ -7831,7 +8177,7 @@ } } }, - "description": "" + "description": "Sign in required." }, "403": { "content": { @@ -7841,7 +8187,7 @@ } } }, - "description": "" + "description": "Missing event permission or wrong staff origin." }, "404": { "content": { @@ -7851,9 +8197,9 @@ } } }, - "description": "" + "description": "Unknown or out-of-scope event/page/image." }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -7861,16 +8207,22 @@ } } }, - "description": "Event state or capacity conflict." + "description": "Rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Private image storage is unavailable." } } - } - }, - "/api/v1/admin/events/{id}/eligible-members/": { - "get": { - "operationId": "api_v1_admin_events_eligible_members_list", - "description": "The roster the staff registration picker reads.\n\nHung off the EVENT rather than the makerspace, so it inherits `_manageable_event`\nand introduces no new authority question: whoever may manage this event may see who\nthey can register for it. A separate makerspace-level member list would have needed\nits own permission answer, and the obvious candidates were all wrong — the\ndirect-loan roster is gated on `ISSUE_DIRECT_LOAN` plus a self-checkout feature an\nevents manager need not hold, and the full membership list is `MANAGE_MAKERSPACE`.\n\nAlready-registered members are excluded: offering someone the picker can only reject\nas a duplicate is an error the interface should not have made available.", - "summary": "List members who can be registered for an event", + }, + "put": { + "operationId": "api_v1_admin_event_series_website_update", "parameters": [ { "in": "path", @@ -7882,8 +8234,28 @@ } ], "tags": [ - "Admin events" + "Event website" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebsiteContentInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/WebsiteContentInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/WebsiteContentInput" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -7894,10 +8266,7 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EventEligibleMember" - } + "$ref": "#/components/schemas/Website" } } }, @@ -7907,12 +8276,11 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected request body or events module disabled." + "description": "Invalid content, stale revision, disabled feature or terminal event." }, "401": { "content": { @@ -7922,7 +8290,7 @@ } } }, - "description": "Authentication is required." + "description": "Sign in required." }, "403": { "content": { @@ -7932,7 +8300,7 @@ } } }, - "description": "Event management is required." + "description": "Missing event permission or wrong staff origin." }, "404": { "content": { @@ -7942,9 +8310,9 @@ } } }, - "description": "Registration not found." + "description": "Unknown or out-of-scope event/page/image." }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -7952,14 +8320,24 @@ } } }, - "description": "Event state or capacity conflict." + "description": "Rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Private image storage is unavailable." } } } }, - "/api/v1/admin/events/{id}/feedback-responses/": { - "get": { - "operationId": "api_v1_admin_events_feedback_responses_retrieve", + "/api/v1/admin/event-series/{id}/website/assets/": { + "post": { + "operationId": "api_v1_admin_event_series_website_assets_create", "parameters": [ { "in": "path", @@ -7971,19 +8349,29 @@ } ], "tags": [ - "Admin events" + "Event website" ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/WebsiteAssetInput" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FeedbackResponseList" + "$ref": "#/components/schemas/WebsiteAsset" } } }, @@ -7997,7 +8385,17 @@ } } }, - "description": "Invalid request." + "description": "Invalid content, stale revision, disabled feature or terminal event." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Sign in required." }, "403": { "content": { @@ -8007,7 +8405,7 @@ } } }, - "description": "Event management is required." + "description": "Missing event permission or wrong staff origin." }, "404": { "content": { @@ -8017,9 +8415,9 @@ } } }, - "description": "Event resource not found." + "description": "Unknown or out-of-scope event/page/image." }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -8027,15 +8425,33 @@ } } }, - "description": "Event state conflict." + "description": "Rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Private image storage is unavailable." } } } }, - "/api/v1/admin/events/{id}/feedback-survey/": { + "/api/v1/admin/event-series/{id}/website/assets/{asset_id}": { "get": { - "operationId": "api_v1_admin_events_feedback_survey_retrieve", + "operationId": "api_v1_admin_event_series_website_assets_retrieve", "parameters": [ + { + "in": "path", + "name": "asset_id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "id", @@ -8046,7 +8462,7 @@ } ], "tags": [ - "Admin events" + "Event website" ], "security": [ { @@ -8058,11 +8474,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FeedbackSurveyAdminEnvelope" + "type": "string", + "format": "binary" } } }, - "description": "" + "description": "Private WebP image; no-store." }, "400": { "content": { @@ -8072,7 +8489,17 @@ } } }, - "description": "Invalid request." + "description": "Invalid content, stale revision, disabled feature or terminal event." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Sign in required." }, "403": { "content": { @@ -8082,7 +8509,7 @@ } } }, - "description": "Event management is required." + "description": "Missing event permission or wrong staff origin." }, "404": { "content": { @@ -8092,9 +8519,9 @@ } } }, - "description": "Event resource not found." + "description": "Unknown or out-of-scope event/page/image." }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -8102,13 +8529,31 @@ } } }, - "description": "Event state conflict." + "description": "Rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Private image storage is unavailable." } } }, - "put": { - "operationId": "api_v1_admin_events_feedback_survey_update", + "delete": { + "operationId": "api_v1_admin_event_series_website_assets_destroy", "parameters": [ + { + "in": "path", + "name": "asset_id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "id", @@ -8119,45 +8564,28 @@ } ], "tags": [ - "Admin events" + "Event website" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FeedbackSurveyWrite" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/FeedbackSurveyWrite" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/FeedbackSurveyWrite" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "204": { + "description": "No response body" + }, + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FeedbackSurvey" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Invalid content, stale revision, disabled feature or terminal event." }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -8165,7 +8593,7 @@ } } }, - "description": "Invalid request." + "description": "Sign in required." }, "403": { "content": { @@ -8175,7 +8603,7 @@ } } }, - "description": "Event management is required." + "description": "Missing event permission or wrong staff origin." }, "404": { "content": { @@ -8185,9 +8613,9 @@ } } }, - "description": "Event resource not found." + "description": "Unknown or out-of-scope event/page/image." }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -8195,14 +8623,24 @@ } } }, - "description": "Event state conflict." + "description": "Rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Private image storage is unavailable." } } } }, - "/api/v1/admin/events/{id}/feedback-survey/close/": { + "/api/v1/admin/event-series/{id}/website/release/": { "post": { - "operationId": "api_v1_admin_events_feedback_survey_close_create", + "operationId": "api_v1_admin_event_series_website_release_create", "parameters": [ { "in": "path", @@ -8214,8 +8652,28 @@ } ], "tags": [ - "Admin events" + "Event website" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebsiteReleaseInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/WebsiteReleaseInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/WebsiteReleaseInput" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -8226,7 +8684,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FeedbackSurvey" + "$ref": "#/components/schemas/Website" } } }, @@ -8240,7 +8698,17 @@ } } }, - "description": "Invalid request." + "description": "Invalid content, stale revision, disabled feature or terminal event." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Sign in required." }, "403": { "content": { @@ -8250,7 +8718,7 @@ } } }, - "description": "Event management is required." + "description": "Missing event permission or wrong staff origin." }, "404": { "content": { @@ -8260,9 +8728,9 @@ } } }, - "description": "Event resource not found." + "description": "Unknown or out-of-scope event/page/image." }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -8270,14 +8738,24 @@ } } }, - "description": "Event state conflict." + "description": "Rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Private image storage is unavailable." } } } }, - "/api/v1/admin/events/{id}/feedback-survey/open/": { + "/api/v1/admin/event-series/{id}/website/sessions/{slug}/delay/": { "post": { - "operationId": "api_v1_admin_events_feedback_survey_open_create", + "operationId": "api_v1_admin_event_series_website_sessions_delay_create", "parameters": [ { "in": "path", @@ -8286,11 +8764,39 @@ "type": "integer" }, "required": true + }, + { + "in": "path", + "name": "slug", + "schema": { + "type": "string" + }, + "required": true } ], "tags": [ - "Admin events" + "Event website" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebsiteDelayInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/WebsiteDelayInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/WebsiteDelayInput" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -8301,7 +8807,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FeedbackSurvey" + "$ref": "#/components/schemas/Website" } } }, @@ -8315,7 +8821,17 @@ } } }, - "description": "Invalid request." + "description": "Invalid content, stale revision, disabled feature or terminal event." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Sign in required." }, "403": { "content": { @@ -8325,7 +8841,7 @@ } } }, - "description": "Event management is required." + "description": "Missing event permission or wrong staff origin." }, "404": { "content": { @@ -8335,9 +8851,9 @@ } } }, - "description": "Event resource not found." + "description": "Unknown or out-of-scope event/page/image." }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -8345,15 +8861,24 @@ } } }, - "description": "Event state conflict." + "description": "Rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Private image storage is unavailable." } } } }, - "/api/v1/admin/events/{id}/image": { - "post": { - "operationId": "api_v1_admin_events_image_create", - "summary": "Create an event image upload URL", + "/api/v1/admin/event-staff-grants/{id}/": { + "patch": { + "operationId": "api_v1_admin_event_staff_grants_partial_update", "parameters": [ { "in": "path", @@ -8371,21 +8896,20 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" + "$ref": "#/components/schemas/PatchedStaffGrantPermissions" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" + "$ref": "#/components/schemas/PatchedStaffGrantPermissions" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" + "$ref": "#/components/schemas/PatchedStaffGrantPermissions" } } - }, - "required": true + } }, "security": [ { @@ -8393,140 +8917,102 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicImageUploadResponse" + "$ref": "#/components/schemas/StaffGrant" } } }, "description": "" }, "400": { - "description": "Invalid image upload request." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "Event management access is required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Event not found." - }, - "503": { - "description": "Public image storage is unavailable." - } - } - }, - "put": { - "operationId": "api_v1_admin_events_image_update", - "summary": "Attach an uploaded image to an event", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin events" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" - } - } + "description": "" }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventAdmin" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "400": { - "description": "Invalid image object key or size." - }, - "403": { - "description": "Event management access is required." - }, - "404": { - "description": "Event not found." + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "503": { - "description": "Public image storage is unavailable." - } - } - }, - "delete": { - "operationId": "api_v1_admin_events_image_destroy", - "summary": "Clear an event image", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin events" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventAdmin" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" - }, - "403": { - "description": "Event management access is required." - }, - "404": { - "description": "Event not found." } } } }, - "/api/v1/admin/events/{id}/invites/": { - "get": { - "operationId": "api_v1_admin_events_invites_retrieve", + "/api/v1/admin/event-staff-grants/{id}/revoke/": { + "post": { + "operationId": "api_v1_admin_event_staff_grants_revoke_create", "parameters": [ { "in": "path", @@ -8535,13 +9021,6 @@ "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer" - } } ], "tags": [ @@ -8557,7 +9036,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InviteList" + "$ref": "#/components/schemas/StaffGrant" } } }, @@ -8567,12 +9046,11 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid field values, disabled events module or disabled feature." + "description": "" }, "401": { "content": { @@ -8645,9 +9123,11 @@ "description": "" } } - }, + } + }, + "/api/v1/admin/event-staff-invites/{id}/revoke/": { "post": { - "operationId": "api_v1_admin_events_invites_create", + "operationId": "api_v1_admin_event_staff_invites_revoke_create", "parameters": [ { "in": "path", @@ -8661,36 +9141,17 @@ "tags": [ "Admin events" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InviteInput" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/InviteInput" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/InviteInput" - } - } - } - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Invite" + "$ref": "#/components/schemas/StaffGrantInvite" } } }, @@ -8700,12 +9161,11 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid field values, disabled events module or disabled feature." + "description": "" }, "401": { "content": { @@ -8780,38 +9240,27 @@ } } }, - "/api/v1/admin/events/{id}/organizers/": { - "put": { - "operationId": "api_v1_admin_events_organizers_update", - "summary": "Replace an event's organization organizers", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "/api/v1/admin/event-staff-invites/redeem/": { + "post": { + "operationId": "api_v1_admin_event_staff_invites_redeem_create", "tags": [ - "Admin events" + "Events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventOrganizerReplace" + "$ref": "#/components/schemas/StaffGrantRedeem" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/EventOrganizerReplace" + "$ref": "#/components/schemas/StaffGrantRedeem" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/EventOrganizerReplace" + "$ref": "#/components/schemas/StaffGrantRedeem" } } }, @@ -8827,14 +9276,21 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventOrganizerList" + "$ref": "#/components/schemas/StaffGrant" } } }, "description": "" }, "400": { - "description": "Invalid or unavailable organization." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "401": { "content": { @@ -8867,62 +9323,26 @@ "description": "" }, "409": { - "description": "Concurrent event state conflict." - } - } - } - }, - "/api/v1/admin/events/{id}/promo-codes/": { - "get": { - "operationId": "api_v1_admin_events_promo_codes_retrieve", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer" - } - } - ], - "tags": [ - "Admin events" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PromoList" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "400": { + "423": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid field values, disabled events module or disabled feature." + "description": "" }, - "401": { + "429": { "content": { "application/json": { "schema": { @@ -8932,7 +9352,7 @@ }, "description": "" }, - "403": { + "503": { "content": { "application/json": { "schema": { @@ -8941,18 +9361,44 @@ } }, "description": "" - }, - "404": { + } + } + } + }, + "/api/v1/admin/events/{id}/": { + "get": { + "operationId": "api_v1_admin_events_retrieve", + "summary": "Retrieve an event", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin events" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/EventAdmin" } } }, "description": "" }, - "409": { + "400": { "content": { "application/json": { "schema": { @@ -8962,7 +9408,7 @@ }, "description": "" }, - "423": { + "401": { "content": { "application/json": { "schema": { @@ -8972,7 +9418,7 @@ }, "description": "" }, - "429": { + "403": { "content": { "application/json": { "schema": { @@ -8982,7 +9428,7 @@ }, "description": "" }, - "503": { + "404": { "content": { "application/json": { "schema": { @@ -8994,8 +9440,9 @@ } } }, - "post": { - "operationId": "api_v1_admin_events_promo_codes_create", + "patch": { + "operationId": "api_v1_admin_events_partial_update", + "summary": "Update an event", "parameters": [ { "in": "path", @@ -9013,17 +9460,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PromoInput" + "$ref": "#/components/schemas/PatchedEventWrite" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PromoInput" + "$ref": "#/components/schemas/PatchedEventWrite" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PromoInput" + "$ref": "#/components/schemas/PatchedEventWrite" } } } @@ -9034,11 +9481,11 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Promo" + "$ref": "#/components/schemas/EventAdmin" } } }, @@ -9053,37 +9500,7 @@ } } }, - "description": "Invalid field values, disabled events module or disabled feature." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Invalid event details." }, "409": { "content": { @@ -9093,9 +9510,9 @@ } } }, - "description": "" + "description": "Event state or capacity conflict." }, - "423": { + "401": { "content": { "application/json": { "schema": { @@ -9105,7 +9522,7 @@ }, "description": "" }, - "429": { + "403": { "content": { "application/json": { "schema": { @@ -9115,7 +9532,7 @@ }, "description": "" }, - "503": { + "404": { "content": { "application/json": { "schema": { @@ -9128,10 +9545,9 @@ } } }, - "/api/v1/admin/events/{id}/publish/": { - "post": { - "operationId": "api_v1_admin_events_publish_create", - "summary": "Publish an event", + "/api/v1/admin/events/{id}/badge-template/": { + "get": { + "operationId": "api_v1_admin_events_badge_template_retrieve", "parameters": [ { "in": "path", @@ -9155,28 +9571,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventAdmin" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Invalid event details." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/BadgeTemplate" } } }, @@ -9201,24 +9596,11 @@ } }, "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Event state or capacity conflict." } } - } - }, - "/api/v1/admin/events/{id}/registrations/": { - "get": { - "operationId": "api_v1_admin_events_registrations_retrieve", - "summary": "List event registrations", + }, + "put": { + "operationId": "api_v1_admin_events_badge_template_update", "parameters": [ { "in": "path", @@ -9232,6 +9614,25 @@ "tags": [ "Admin events" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadgeTemplate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/BadgeTemplate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/BadgeTemplate" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -9242,7 +9643,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventRegistrationListResponse" + "$ref": "#/components/schemas/BadgeTemplate" } } }, @@ -9257,17 +9658,7 @@ } } }, - "description": "Unexpected request body or events module disabled." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Authentication is required." + "description": "Invalid event details." }, "403": { "content": { @@ -9277,7 +9668,7 @@ } } }, - "description": "Event management is required." + "description": "" }, "404": { "content": { @@ -9287,7 +9678,7 @@ } } }, - "description": "Registration not found." + "description": "" }, "409": { "content": { @@ -9300,10 +9691,12 @@ "description": "Event state or capacity conflict." } } - }, + } + }, + "/api/v1/admin/events/{id}/badges.pdf": { "post": { - "operationId": "api_v1_admin_events_registrations_create", - "summary": "Register a member for an event", + "operationId": "api_v1_admin_events_badges.pdf_create", + "description": "Private audited positioned badge PDF, 30/hour per account. No contacts or bearer QR. Custom answers require registration management and tenant VIEW_AUDIT.", "parameters": [ { "in": "path", @@ -9321,17 +9714,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventStaffRegistration" + "$ref": "#/components/schemas/BadgePdfRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/EventStaffRegistration" + "$ref": "#/components/schemas/BadgePdfRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/EventStaffRegistration" + "$ref": "#/components/schemas/BadgePdfRequest" } } }, @@ -9343,27 +9736,29 @@ } ], "responses": { - "201": { + "200": { "content": { - "application/json": { + "application/pdf": { "schema": { - "$ref": "#/components/schemas/EventRegistrationAdmin" + "type": "string", + "format": "binary" } } }, - "description": "" + "description": "Print-ready badge PDF." }, "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": {} } } }, - "description": "Invalid registration." + "description": "Invalid event details." }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -9373,7 +9768,7 @@ }, "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -9383,7 +9778,7 @@ }, "description": "" }, - "404": { + "409": { "content": { "application/json": { "schema": { @@ -9391,9 +9786,9 @@ } } }, - "description": "" + "description": "Event state or capacity conflict." }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -9401,14 +9796,15 @@ } } }, - "description": "Event state or capacity conflict." + "description": "" } } } }, - "/api/v1/admin/events/{id}/ticket-types/": { + "/api/v1/admin/events/{id}/broadcasts/": { "get": { - "operationId": "api_v1_admin_events_ticket_types_retrieve", + "operationId": "api_v1_admin_events_broadcasts_retrieve", + "description": "Requires live event manage_registrations authority and events.broadcasts, notifications and email.", "parameters": [ { "in": "path", @@ -9439,7 +9835,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TicketList" + "$ref": "#/components/schemas/EventBroadcastList" } } }, @@ -9529,7 +9925,8 @@ } }, "post": { - "operationId": "api_v1_admin_events_ticket_types_create", + "operationId": "api_v1_admin_events_broadcasts_create", + "description": "Compose an audited draft. Requires manage_registrations and events.broadcasts, notifications and email; placeholders never include credentials.", "parameters": [ { "in": "path", @@ -9547,17 +9944,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TicketInput" + "$ref": "#/components/schemas/EventBroadcastInput" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/TicketInput" + "$ref": "#/components/schemas/EventBroadcastInput" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/TicketInput" + "$ref": "#/components/schemas/EventBroadcastInput" } } }, @@ -9573,7 +9970,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Ticket" + "$ref": "#/components/schemas/EventBroadcast" } } }, @@ -9663,46 +10060,19 @@ } } }, - "/api/v1/admin/evidence/{id}": { - "get": { - "operationId": "api_v1_admin_evidence_retrieve", + "/api/v1/admin/events/{id}/broadcasts/{campaign_id}/cancel/": { + "post": { + "operationId": "api_v1_admin_events_broadcasts_cancel_create", + "description": "Queue or cancel under live event authority. Queue limit: ten broadcasts per hour per makerspace. Cancellation stops unclaimed sends.", "parameters": [ { "in": "path", - "name": "id", + "name": "campaign_id", "schema": { "type": "integer" }, "required": true - } - ], - "tags": [ - "api" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EvidenceGetResponse" - } - } - }, - "description": "" - } - } - } - }, - "/api/v1/admin/inventory/{id}": { - "get": { - "operationId": "api_v1_admin_inventory_retrieve", - "summary": "Retrieve or update inventory product", - "parameters": [ + }, { "in": "path", "name": "id", @@ -9713,7 +10083,7 @@ } ], "tags": [ - "Admin inventory" + "Admin events" ], "security": [ { @@ -9725,30 +10095,109 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InventoryProductAdmin" + "$ref": "#/components/schemas/EventBroadcast" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid field values, disabled events module or disabled feature." }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } - }, - "patch": { - "operationId": "api_v1_admin_inventory_partial_update", - "summary": "Retrieve or update inventory product", + } + }, + "/api/v1/admin/events/{id}/broadcasts/{campaign_id}/queue/": { + "post": { + "operationId": "api_v1_admin_events_broadcasts_queue_create", + "description": "Queue or cancel under live event authority. Queue limit: ten broadcasts per hour per makerspace. Cancellation stops unclaimed sends.", "parameters": [ + { + "in": "path", + "name": "campaign_id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "id", @@ -9759,27 +10208,8 @@ } ], "tags": [ - "Admin inventory" + "Admin events" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedInventoryProductAdminUpdate" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedInventoryProductAdminUpdate" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedInventoryProductAdminUpdate" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -9790,78 +10220,88 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InventoryProductAdminUpdate" + "$ref": "#/components/schemas/EventBroadcast" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid field values, disabled events module or disabled feature." }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "423": { - "description": "Makerspace writes are frozen for tenant migration." - } - } - } - }, - "/api/v1/admin/inventory/{id}/adjust-quantity": { - "post": { - "operationId": "api_v1_admin_inventory_adjust_quantity_create", - "summary": "Adjust inventory quantity buckets", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true - } - ], - "tags": [ - "Admin inventory" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InventoryQuantityAdjustment" + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/InventoryQuantityAdjustment" + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/InventoryQuantityAdjustment" + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } - } + }, + "description": "" }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InventoryProductAdmin" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -9870,10 +10310,10 @@ } } }, - "/api/v1/admin/inventory/{id}/chain-of-custody": { - "get": { - "operationId": "api_v1_admin_inventory_chain_of_custody_retrieve", - "summary": "Read-only chain of custody for one inventory item", + "/api/v1/admin/events/{id}/cancel/": { + "post": { + "operationId": "api_v1_admin_events_cancel_create", + "summary": "Cancel an event", "parameters": [ { "in": "path", @@ -9882,18 +10322,10 @@ "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - }, - "description": "Maximum history events to return. Defaults to 200; capped at 500." } ], "tags": [ - "Admin inventory" + "Admin events" ], "security": [ { @@ -9905,7 +10337,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InventoryChainOfCustodyResponse" + "$ref": "#/components/schemas/EventAdmin" } } }, @@ -9919,7 +10351,7 @@ } } }, - "description": "Invalid request." + "description": "" }, "401": { "content": { @@ -9929,7 +10361,7 @@ } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { @@ -9939,7 +10371,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -9949,7 +10381,7 @@ } } }, - "description": "Not found." + "description": "" }, "409": { "content": { @@ -9959,15 +10391,15 @@ } } }, - "description": "Workflow conflict." + "description": "Event state or capacity conflict." } } } }, - "/api/v1/admin/inventory/{id}/image": { + "/api/v1/admin/events/{id}/certificate-artwork/": { "post": { - "operationId": "api_v1_admin_inventory_image_create", - "summary": "Create an inventory product image upload URL", + "operationId": "api_v1_admin_events_certificate_artwork_create", + "description": "Reserve owned private artwork and issue a ten-minute staging-only PUT. PNG/JPEG, at most 10 MiB. Final bytes are server-owned.", "parameters": [ { "in": "path", @@ -9979,23 +10411,23 @@ } ], "tags": [ - "Admin inventory" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" + "$ref": "#/components/schemas/CertificateArtworkPresign" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" + "$ref": "#/components/schemas/CertificateArtworkPresign" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" + "$ref": "#/components/schemas/CertificateArtworkPresign" } } }, @@ -10011,84 +10443,98 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicImageUploadResponse" + "$ref": "#/components/schemas/CertificateArtworkUpload" } } }, "description": "" }, "400": { - "description": "Invalid image upload request." - }, - "503": { - "description": "Public image storage is unavailable." - } - } - }, - "put": { - "operationId": "api_v1_admin_inventory_image_update", - "summary": "Attach an uploaded image to an inventory product", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true - } - ], - "tags": [ - "Admin inventory" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } - } + }, + "description": "" }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InventoryProductAdmin" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "400": { - "description": "Invalid image object key or size." + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "503": { - "description": "Public image storage is unavailable." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } - }, - "delete": { - "operationId": "api_v1_admin_inventory_image_destroy", - "summary": "Clear an inventory product image", + } + }, + "/api/v1/admin/events/{id}/certificate-artwork/{artwork_id}/": { + "get": { + "operationId": "api_v1_admin_events_certificate_artwork_retrieve", + "description": "Read private artwork with live event edit authority; no public artwork route or object URL.", "parameters": [ + { + "in": "path", + "name": "artwork_id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "id", @@ -10099,7 +10545,7 @@ } ], "tags": [ - "Admin inventory" + "Admin events" ], "security": [ { @@ -10108,10 +10554,81 @@ ], "responses": { "200": { + "content": { + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "" + }, + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InventoryProductAdmin" + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -10120,11 +10637,19 @@ } } }, - "/api/v1/admin/inventory/{id}/lending-history": { - "get": { - "operationId": "api_v1_admin_inventory_lending_history_retrieve", - "summary": "Per-item lending history (last borrower + last 3 lends)", + "/api/v1/admin/events/{id}/certificate-artwork/{artwork_id}/finalize/": { + "post": { + "operationId": "api_v1_admin_events_certificate_artwork_finalize_create", + "description": "Validate bounded decoded pixels, strip metadata and promote exactly once. Quota and authority rechecked on attachment. Retries are idempotent.", "parameters": [ + { + "in": "path", + "name": "artwork_id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "id", @@ -10135,7 +10660,7 @@ } ], "tags": [ - "Admin inventory" + "Admin events" ], "security": [ { @@ -10147,64 +10672,77 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LendingHistoryResponse" + "$ref": "#/components/schemas/CertificateArtwork" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/inventory/{id}/needs-fix": { - "post": { - "operationId": "api_v1_admin_inventory_needs_fix_create", - "description": "Move units onto the shelf, back to available, or out of inventory.", - "summary": "Move inventory units to, from, or out of the to-be-fixed shelf", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true - } - ], - "tags": [ - "Admin inventory" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NeedsFixAction" + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/NeedsFixAction" + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/NeedsFixAction" + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } - } + }, + "description": "" }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InventoryProductAdmin" + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -10213,10 +10751,10 @@ } } }, - "/api/v1/admin/inventory/{id}/qr-history": { + "/api/v1/admin/events/{id}/certificate-template/": { "get": { - "operationId": "api_v1_admin_inventory_qr_history_retrieve", - "summary": "List QR scan history for an inventory product", + "operationId": "api_v1_admin_events_certificate_template_retrieve", + "description": "Current immutable revision or default layout. Requires live edit_details and events.certificate_designer.", "parameters": [ { "in": "path", @@ -10228,7 +10766,7 @@ } ], "tags": [ - "Admin inventory" + "Admin events" ], "security": [ { @@ -10240,120 +10778,87 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProductQrHistory" + "$ref": "#/components/schemas/CertificateTemplateRevision" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/inventory/{product_pk}/assets": { - "get": { - "operationId": "api_v1_admin_inventory_assets_list", - "summary": "List individual assets for an inventory product", - "parameters": [ - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } }, - { - "name": "page_size", - "required": false, - "in": "query", - "description": "Number of results to return per page.", - "schema": { - "type": "integer" - } + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, - { - "in": "path", - "name": "product_pk", - "schema": { - "type": "integer" + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true - } - ], - "tags": [ - "Admin inventory" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "" + }, + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedInventoryAssetAdminList" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/inventory/needs-fix": { - "get": { - "operationId": "api_v1_admin_inventory_needs_fix_list", - "description": "The to-be-fixed shelf: products that currently have units awaiting repair.", - "parameters": [ - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } }, - { - "name": "page_size", - "required": false, - "in": "query", - "description": "Number of results to return per page.", - "schema": { - "type": "integer" - } - } - ], - "tags": [ - "api" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedInventoryProductAdminList" + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" } } - } - }, - "/api/v1/admin/invitation-requests/{id}/decline": { + }, "post": { - "operationId": "api_v1_admin_invitation_requests_decline_create", - "description": "Requires manage_members in this makerspace. Staff role administration and membership plan definitions require manage_makerspace.", - "summary": "Decline an invitation request", + "operationId": "api_v1_admin_events_certificate_template_create", + "description": "Create a new immutable revision. Artwork must be promoted and owned by this event; issuance snapshots never change.", "parameters": [ { "in": "path", @@ -10365,19 +10870,39 @@ } ], "tags": [ - "Admin memberships" + "Admin events" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificateTemplateWrite" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/CertificateTemplateWrite" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/CertificateTemplateWrite" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvitationRequest" + "$ref": "#/components/schemas/CertificateTemplateRevision" } } }, @@ -10432,15 +10957,34 @@ } }, "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/invitation-requests/{id}/invite": { + "/api/v1/admin/events/{id}/certificate-template/preview.pdf": { "post": { - "operationId": "api_v1_admin_invitation_requests_invite_create", - "description": "Requires manage_members in this makerspace. Staff role administration and membership plan definitions require manage_makerspace.", - "summary": "Turn an invitation request into a membership invitation", + "operationId": "api_v1_admin_events_certificate_template_preview.pdf_create", + "description": "Preview with fixed sample recipient; same geometry, artwork and overflow rules as issuance. No recipient information is accepted.", "parameters": [ { "in": "path", @@ -10452,23 +10996,23 @@ } ], "tags": [ - "Admin memberships" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RoleId" + "$ref": "#/components/schemas/CertificateTemplateWrite" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/RoleId" + "$ref": "#/components/schemas/CertificateTemplateWrite" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/RoleId" + "$ref": "#/components/schemas/CertificateTemplateWrite" } } }, @@ -10482,13 +11026,14 @@ "responses": { "200": { "content": { - "application/json": { + "application/pdf": { "schema": { - "$ref": "#/components/schemas/InvitationRequest" + "type": "string", + "format": "binary" } } }, - "description": "" + "description": "Private server-rendered certificate preview." }, "400": { "content": { @@ -10539,27 +11084,46 @@ } }, "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/kiosk-replays/{id}/": { + "/api/v1/admin/events/{id}/check-in/offline-roster/": { "get": { - "operationId": "api_v1_admin_kiosk_replays_retrieve", - "summary": "Inspect one current-action-scoped failed handover", + "operationId": "api_v1_admin_events_check_in_offline_roster_retrieve", + "summary": "Download a minimal expiring offline check-in roster", "parameters": [ { "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Admin kiosk recovery" + "Admin events" ], "security": [ { @@ -10571,7 +11135,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReplayRecovery" + "$ref": "#/components/schemas/OfflineRosterResponse" } } }, @@ -10585,7 +11149,7 @@ } } }, - "description": "Invalid filter/resolution or disabled required module." + "description": "Feature disabled or invalid batch." }, "401": { "content": { @@ -10595,7 +11159,7 @@ } } }, - "description": "Authentication required." + "description": "Authentication or lease failed." }, "403": { "content": { @@ -10605,7 +11169,7 @@ } } }, - "description": "Account, current action or origin refused." + "description": "Event authority changed." }, "404": { "content": { @@ -10615,7 +11179,7 @@ } } }, - "description": "Failure is unavailable in your scope." + "description": "Event not found." }, "409": { "content": { @@ -10625,9 +11189,9 @@ } } }, - "description": "Resolution/replacement conflict." + "description": "Roster window closed." }, - "423": { + "410": { "content": { "application/json": { "schema": { @@ -10635,45 +11199,63 @@ } } }, - "description": "Tenant source gate closed." + "description": "Synchronization deadline passed." + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Roster exceeds the offline limit." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Request rate limit exceeded." } } } }, - "/api/v1/admin/kiosk-replays/{id}/resolve/": { + "/api/v1/admin/events/{id}/check-in/offline-sync/": { "post": { - "operationId": "api_v1_admin_kiosk_replays_resolve_create", - "description": "Acknowledgement records no physical change. Replacement links only a fresh succeeded replay for the same known subject; this endpoint never executes handovers.", - "summary": "Append an idempotent review of a failed handover", + "operationId": "api_v1_admin_events_check_in_offline_sync_create", + "summary": "Synchronize queued offline event check-ins", "parameters": [ { "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Admin kiosk recovery" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReplayResolution" + "$ref": "#/components/schemas/OfflineCheckInSyncRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ReplayResolution" + "$ref": "#/components/schemas/OfflineCheckInSyncRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ReplayResolution" + "$ref": "#/components/schemas/OfflineCheckInSyncRequest" } } }, @@ -10689,7 +11271,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReplayRecovery" + "$ref": "#/components/schemas/OfflineCheckInSyncResponse" } } }, @@ -10703,7 +11285,7 @@ } } }, - "description": "Invalid filter/resolution or disabled required module." + "description": "Feature disabled or invalid batch." }, "401": { "content": { @@ -10713,7 +11295,7 @@ } } }, - "description": "Authentication required." + "description": "Authentication or lease failed." }, "403": { "content": { @@ -10723,7 +11305,7 @@ } } }, - "description": "Account, current action or origin refused." + "description": "Event authority changed." }, "404": { "content": { @@ -10733,7 +11315,7 @@ } } }, - "description": "Failure is unavailable in your scope." + "description": "Event not found." }, "409": { "content": { @@ -10743,9 +11325,9 @@ } } }, - "description": "Resolution/replacement conflict." + "description": "Roster window closed." }, - "423": { + "410": { "content": { "application/json": { "schema": { @@ -10753,15 +11335,35 @@ } } }, - "description": "Tenant source gate closed." + "description": "Synchronization deadline passed." + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Roster exceeds the offline limit." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Request rate limit exceeded." } } } }, - "/api/v1/admin/kiosk-stations/{id}/": { - "get": { - "operationId": "api_v1_admin_kiosk_stations_retrieve", - "summary": "Read one kiosk Station without its PIN", + "/api/v1/admin/events/{id}/check-in/resolve/": { + "post": { + "operationId": "api_v1_admin_events_check_in_resolve_create", + "summary": "Resolve an event check-in token", "parameters": [ { "in": "path", @@ -10773,8 +11375,28 @@ } ], "tags": [ - "Admin kiosk stations" + "Admin events" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventCheckInResolveRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/EventCheckInResolveRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/EventCheckInResolveRequest" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -10785,22 +11407,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KioskStation" + "$ref": "#/components/schemas/EventCheckInResolveResponse" } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Feature disabled or invalid request." - }, "403": { "content": { "application/json": { @@ -10809,7 +11421,7 @@ } } }, - "description": "Makerspace access or step-up denied." + "description": "Event access denied." }, "404": { "content": { @@ -10819,17 +11431,7 @@ } } }, - "description": "Station not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Rotate the PIN instead of revealing." + "description": "Registration not found." }, "429": { "content": { @@ -10840,22 +11442,14 @@ } }, "description": "Request rate limit exceeded." - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Credential secrets are unavailable." } } - }, - "delete": { - "operationId": "api_v1_admin_kiosk_stations_destroy", - "summary": "Disable a kiosk Station and revoke its leases", + } + }, + "/api/v1/admin/events/{id}/check-in/station/": { + "get": { + "operationId": "api_v1_admin_events_check_in_station_retrieve", + "summary": "Read venue-station configuration without revealing its PIN", "parameters": [ { "in": "path", @@ -10867,7 +11461,7 @@ } ], "tags": [ - "Admin kiosk stations" + "Admin events" ], "security": [ { @@ -10879,7 +11473,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KioskStation" + "$ref": "#/components/schemas/StationStatus" } } }, @@ -10903,7 +11497,7 @@ } } }, - "description": "Makerspace access or step-up denied." + "description": "Event access or step-up denied." }, "404": { "content": { @@ -10913,7 +11507,7 @@ } } }, - "description": "Station not found." + "description": "Event not found." }, "409": { "content": { @@ -10946,12 +11540,10 @@ "description": "Credential secrets are unavailable." } } - } - }, - "/api/v1/admin/kiosk-stations/{id}/reveal/": { - "post": { - "operationId": "api_v1_admin_kiosk_stations_reveal_create", - "summary": "Reveal a kiosk Station PIN after password step-up", + }, + "delete": { + "operationId": "api_v1_admin_events_check_in_station_destroy", + "summary": "Disable a venue check-in station", "parameters": [ { "in": "path", @@ -10963,28 +11555,8 @@ } ], "tags": [ - "Admin kiosk stations" + "Admin events" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KioskStationReveal" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/KioskStationReveal" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/KioskStationReveal" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -10995,7 +11567,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KioskStationRevealResponse" + "$ref": "#/components/schemas/StationStatus" } } }, @@ -11019,7 +11591,7 @@ } } }, - "description": "Makerspace access or step-up denied." + "description": "Event access or step-up denied." }, "404": { "content": { @@ -11029,7 +11601,7 @@ } } }, - "description": "Station not found." + "description": "Event not found." }, "409": { "content": { @@ -11064,10 +11636,10 @@ } } }, - "/api/v1/admin/kiosk-stations/{id}/rotate/": { + "/api/v1/admin/events/{id}/check-in/station/reveal/": { "post": { - "operationId": "api_v1_admin_kiosk_stations_rotate_create", - "summary": "Rotate a kiosk Station PIN (re-enables it)", + "operationId": "api_v1_admin_events_check_in_station_reveal_create", + "summary": "Reveal the current station PIN after password step-up", "parameters": [ { "in": "path", @@ -11079,8 +11651,28 @@ } ], "tags": [ - "Admin kiosk stations" + "Admin events" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StationReveal" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/StationReveal" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/StationReveal" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -11091,7 +11683,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KioskStationSecret" + "$ref": "#/components/schemas/StationRevealResponse" } } }, @@ -11115,7 +11707,7 @@ } } }, - "description": "Makerspace access or step-up denied." + "description": "Event access or step-up denied." }, "404": { "content": { @@ -11125,7 +11717,7 @@ } } }, - "description": "Station not found." + "description": "Event not found." }, "409": { "content": { @@ -11160,84 +11752,22 @@ } } }, - "/api/v1/admin/ledger": { - "get": { - "operationId": "api_v1_admin_ledger_retrieve", - "summary": "List outstanding inventory loans across all makerspaces", + "/api/v1/admin/events/{id}/check-in/station/rotate/": { + "post": { + "operationId": "api_v1_admin_events_check_in_station_rotate_create", + "summary": "Create or rotate an event-scoped eight-digit station PIN", "parameters": [ { - "in": "query", - "name": "makerspace", - "schema": { - "type": "integer" - } - }, - { - "in": "query", - "name": "overdue", - "schema": { - "type": "boolean" - } - }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer" - } - }, - { - "in": "query", - "name": "page_size", + "in": "path", + "name": "id", "schema": { "type": "integer" - } - }, - { - "in": "query", - "name": "search", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "sort", - "schema": { - "type": "string", - "enum": [ - "-due", - "-holder", - "-item_name", - "-makerspace_id", - "-quantity", - "-since", - "-source", - "due", - "holder", - "item_name", - "makerspace_id", - "quantity", - "since", - "source" - ] - } - }, - { - "in": "query", - "name": "source", - "schema": { - "type": "string", - "enum": [ - "direct", - "reviewed", - "self_checkout" - ] - } + }, + "required": true } ], "tags": [ - "Ledger" + "Admin events" ], "security": [ { @@ -11249,165 +11779,43 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LedgerResponse" + "$ref": "#/components/schemas/StationRotation" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/ledger/export": { - "get": { - "operationId": "api_v1_admin_ledger_export_retrieve", - "summary": "Export outstanding inventory loans across all makerspaces", - "parameters": [ - { - "in": "query", - "name": "format", - "schema": { - "type": "string", - "enum": [ - "csv", - "xlsx" - ] - } - }, - { - "in": "query", - "name": "makerspace", - "schema": { - "type": "integer" - } - }, - { - "in": "query", - "name": "overdue", - "schema": { - "type": "boolean" - } - }, - { - "in": "query", - "name": "search", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "sort", - "schema": { - "type": "string", - "enum": [ - "-due", - "-holder", - "-item_name", - "-makerspace_id", - "-quantity", - "-since", - "-source", - "due", - "holder", - "item_name", - "makerspace_id", - "quantity", - "since", - "source" - ] - } }, - { - "in": "query", - "name": "source", - "schema": { - "type": "string", - "enum": [ - "direct", - "reviewed", - "self_checkout" - ] - } - } - ], - "tags": [ - "Ledger" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "400": { "content": { - "text/csv": { + "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/HardwareRequestError" } - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + } + }, + "description": "Feature disabled or invalid request." + }, + "403": { + "content": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" - } - } - } - }, - "/api/v1/admin/machine-service-report": { - "get": { - "operationId": "api_v1_admin_machine_service_report_retrieve", - "summary": "Retrieve aggregate machine-service report", - "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "machine_type", - "schema": { - "type": "string" - } + "description": "Event access or step-up denied." }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - } - ], - "tags": [ - "Admin machine service" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceReportResponse" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Event not found." }, - "400": { + "409": { "content": { "application/json": { "schema": { @@ -11415,23 +11823,35 @@ } } }, - "description": "Invalid request." - }, - "401": { - "description": "Authentication credentials were not provided." + "description": "Rotate the PIN instead of revealing." }, - "403": { - "description": "Machine management permission required." + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Request rate limit exceeded." }, - "404": { - "description": "Not found." + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Credential secrets are unavailable." } } } }, - "/api/v1/admin/machine-service/consumable-pools/{id}": { + "/api/v1/admin/events/{id}/collaborators/": { "get": { - "operationId": "api_v1_admin_machine_service_consumable_pools_retrieve", + "operationId": "api_v1_admin_events_collaborators_list", + "summary": "List an event's collaborators", "parameters": [ { "in": "path", @@ -11443,7 +11863,7 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], "security": [ { @@ -11455,70 +11875,50 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PrinterPool" + "type": "array", + "items": { + "$ref": "#/components/schemas/EventCollaborator" + } } } }, "description": "" - } - } - }, - "patch": { - "operationId": "api_v1_admin_machine_service_consumable_pools_partial_update", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin machine service" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedPrinterPoolVisibility" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedPrinterPoolVisibility" + "description": "Invalid collaboration request." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedPrinterPoolVisibility" - } - } - } - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "Event management access denied." + }, + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PrinterPool" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Event collaboration not found." } } - } - }, - "/api/v1/admin/machine-service/consumable-pools/{id}/adjustments": { - "post": { - "operationId": "api_v1_admin_machine_service_consumable_pools_adjustments_create", + }, + "put": { + "operationId": "api_v1_admin_events_collaborators_update", + "summary": "Replace an event's collaborators", "parameters": [ { "in": "path", @@ -11530,23 +11930,23 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PrinterPoolCorrection" + "$ref": "#/components/schemas/EventCollaboratorReplace" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PrinterPoolCorrection" + "$ref": "#/components/schemas/EventCollaboratorReplace" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PrinterPoolCorrection" + "$ref": "#/components/schemas/EventCollaboratorReplace" } } }, @@ -11562,40 +11962,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PrinterPool" + "type": "array", + "items": { + "$ref": "#/components/schemas/EventCollaborator" + } } } }, "description": "" - } - } - } - }, - "/api/v1/admin/machine-service/files/{id}": { - "delete": { - "operationId": "api_v1_admin_machine_service_files_destroy", - "summary": "Delete a staged service attachment", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin machine service" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "204": { - "description": "No response body" }, "400": { "content": { @@ -11605,18 +11979,19 @@ } } }, - "description": "Invalid attachment input." - }, - "401": { - "description": "Authentication required." + "description": "Invalid collaboration request." }, "403": { - "description": "Machine management permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management access denied." }, "404": { - "description": "Service request or file was not found." - }, - "409": { "content": { "application/json": { "schema": { @@ -11624,18 +11999,15 @@ } } }, - "description": "Attachment conflict." - }, - "503": { - "description": "Private storage is unavailable." + "description": "Event collaboration not found." } } } }, - "/api/v1/admin/machine-service/files/{id}/url": { - "get": { - "operationId": "api_v1_admin_machine_service_files_url_retrieve", - "summary": "Create a signed service attachment URL", + "/api/v1/admin/events/{id}/complete/": { + "post": { + "operationId": "api_v1_admin_events_complete_create", + "summary": "Complete an event", "parameters": [ { "in": "path", @@ -11647,7 +12019,7 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], "security": [ { @@ -11659,7 +12031,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ServiceFileUrl" + "$ref": "#/components/schemas/EventAdmin" } } }, @@ -11673,16 +12045,37 @@ } } }, - "description": "Invalid attachment input." + "description": "" }, "401": { - "description": "Authentication required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "Machine management permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Service request or file was not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "409": { "content": { @@ -11692,18 +12085,15 @@ } } }, - "description": "Attachment conflict." - }, - "503": { - "description": "Private storage is unavailable." + "description": "Event state or capacity conflict." } } } }, - "/api/v1/admin/machine-service/payments/{id}/mark-offline": { + "/api/v1/admin/events/{id}/duplicate/": { "post": { - "operationId": "api_v1_admin_machine_service_payments_mark_offline_create", - "summary": "Mark a machine-service payment paid offline", + "operationId": "api_v1_admin_events_duplicate_create", + "summary": "Duplicate event into a standalone draft", "parameters": [ { "in": "path", @@ -11715,27 +12105,26 @@ } ], "tags": [ - "Payments" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaymentOffline" + "$ref": "#/components/schemas/EventDuplicate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PaymentOffline" + "$ref": "#/components/schemas/EventDuplicate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PaymentOffline" + "$ref": "#/components/schemas/EventDuplicate" } } - }, - "required": true + } }, "security": [ { @@ -11743,11 +12132,11 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StaffPayment" + "$ref": "#/components/schemas/EventAdmin" } } }, @@ -11761,7 +12150,7 @@ } } }, - "description": "Missing or invalid settlement receipt." + "description": "" }, "401": { "content": { @@ -11771,7 +12160,7 @@ } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { @@ -11781,7 +12170,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -11791,7 +12180,7 @@ } } }, - "description": "Payment not found or out of machine scope." + "description": "" }, "409": { "content": { @@ -11801,15 +12190,26 @@ } } }, - "description": "`payment_terminal`: already reconciled." + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/machine-service/payments/{id}/waive": { - "post": { - "operationId": "api_v1_admin_machine_service_payments_waive_create", - "summary": "Waive a machine-service payment", + "/api/v1/admin/events/{id}/eligible-members/": { + "get": { + "operationId": "api_v1_admin_events_eligible_members_list", + "description": "The roster the staff registration picker reads.\n\nHung off the EVENT rather than the makerspace, so it inherits `_manageable_event`\nand introduces no new authority question: whoever may manage this event may see who\nthey can register for it. A separate makerspace-level member list would have needed\nits own permission answer, and the obvious candidates were all wrong — the\ndirect-loan roster is gated on `ISSUE_DIRECT_LOAN` plus a self-checkout feature an\nevents manager need not hold, and the full membership list is `MANAGE_MAKERSPACE`.\n\nAlready-registered members are excluded: offering someone the picker can only reject\nas a duplicate is an error the interface should not have made available.", + "summary": "List members who can be registered for an event", "parameters": [ { "in": "path", @@ -11821,7 +12221,7 @@ } ], "tags": [ - "Payments" + "Admin events" ], "security": [ { @@ -11833,12 +12233,26 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StaffPayment" + "type": "array", + "items": { + "$ref": "#/components/schemas/EventEligibleMember" + } } } }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Unexpected request body or events module disabled." + }, "401": { "content": { "application/json": { @@ -11847,7 +12261,7 @@ } } }, - "description": "Authentication required." + "description": "Authentication is required." }, "403": { "content": { @@ -11857,7 +12271,7 @@ } } }, - "description": "Permission denied." + "description": "Event management is required." }, "404": { "content": { @@ -11867,7 +12281,7 @@ } } }, - "description": "Payment not found or out of machine scope." + "description": "Registration not found." }, "409": { "content": { @@ -11877,15 +12291,14 @@ } } }, - "description": "`payment_terminal`: already reconciled." + "description": "Event state or capacity conflict." } } } }, - "/api/v1/admin/machine-service/requests/{id}": { + "/api/v1/admin/events/{id}/feedback-responses/": { "get": { - "operationId": "api_v1_admin_machine_service_requests_retrieve", - "summary": "Retrieve a machine service request", + "operationId": "api_v1_admin_events_feedback_responses_retrieve", "parameters": [ { "in": "path", @@ -11897,7 +12310,7 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], "security": [ { @@ -11909,7 +12322,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceRequest" + "$ref": "#/components/schemas/FeedbackResponseList" } } }, @@ -11923,16 +12336,27 @@ } } }, - "description": "Invalid service request input." - }, - "401": { - "description": "Authentication required." + "description": "Invalid request." }, "403": { - "description": "Machine management permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management is required." }, "404": { - "description": "Service request was not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event resource not found." }, "409": { "content": { @@ -11942,15 +12366,14 @@ } } }, - "description": "Service workflow conflict." + "description": "Event state conflict." } } } }, - "/api/v1/admin/machine-service/requests/{id}/accept": { - "post": { - "operationId": "api_v1_admin_machine_service_requests_accept_create", - "summary": "Accept a machine service request", + "/api/v1/admin/events/{id}/feedback-survey/": { + "get": { + "operationId": "api_v1_admin_events_feedback_survey_retrieve", "parameters": [ { "in": "path", @@ -11962,27 +12385,8 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServiceAccept" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ServiceAccept" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ServiceAccept" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -11993,7 +12397,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceRequest" + "$ref": "#/components/schemas/FeedbackSurveyAdminEnvelope" } } }, @@ -12007,16 +12411,27 @@ } } }, - "description": "Invalid service request input." - }, - "401": { - "description": "Authentication required." + "description": "Invalid request." }, "403": { - "description": "Machine management permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management is required." }, "404": { - "description": "Service request was not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event resource not found." }, "409": { "content": { @@ -12026,15 +12441,12 @@ } } }, - "description": "Service workflow conflict." + "description": "Event state conflict." } } - } - }, - "/api/v1/admin/machine-service/requests/{id}/collect": { - "post": { - "operationId": "api_v1_admin_machine_service_requests_collect_create", - "summary": "Mark a machine service request collected", + }, + "put": { + "operationId": "api_v1_admin_events_feedback_survey_update", "parameters": [ { "in": "path", @@ -12046,8 +12458,28 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackSurveyWrite" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/FeedbackSurveyWrite" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/FeedbackSurveyWrite" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -12058,7 +12490,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceRequest" + "$ref": "#/components/schemas/FeedbackSurvey" } } }, @@ -12072,16 +12504,27 @@ } } }, - "description": "Invalid service request input." - }, - "401": { - "description": "Authentication required." + "description": "Invalid request." }, "403": { - "description": "Machine management permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management is required." }, "404": { - "description": "Service request was not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event resource not found." }, "409": { "content": { @@ -12091,15 +12534,14 @@ } } }, - "description": "Service workflow conflict." + "description": "Event state conflict." } } } }, - "/api/v1/admin/machine-service/requests/{id}/complete": { + "/api/v1/admin/events/{id}/feedback-survey/close/": { "post": { - "operationId": "api_v1_admin_machine_service_requests_complete_create", - "summary": "Complete machine service work", + "operationId": "api_v1_admin_events_feedback_survey_close_create", "parameters": [ { "in": "path", @@ -12111,28 +12553,8 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServiceComplete" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ServiceComplete" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ServiceComplete" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -12143,7 +12565,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceRequest" + "$ref": "#/components/schemas/FeedbackSurvey" } } }, @@ -12157,16 +12579,27 @@ } } }, - "description": "Invalid service request input." - }, - "401": { - "description": "Authentication required." + "description": "Invalid request." }, "403": { - "description": "Machine management permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management is required." }, "404": { - "description": "Service request was not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event resource not found." }, "409": { "content": { @@ -12176,15 +12609,14 @@ } } }, - "description": "Service workflow conflict." + "description": "Event state conflict." } } } }, - "/api/v1/admin/machine-service/requests/{id}/fail": { + "/api/v1/admin/events/{id}/feedback-survey/open/": { "post": { - "operationId": "api_v1_admin_machine_service_requests_fail_create", - "summary": "Mark machine service work failed", + "operationId": "api_v1_admin_events_feedback_survey_open_create", "parameters": [ { "in": "path", @@ -12196,28 +12628,8 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServiceFail" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ServiceFail" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ServiceFail" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -12228,7 +12640,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceRequest" + "$ref": "#/components/schemas/FeedbackSurvey" } } }, @@ -12242,16 +12654,27 @@ } } }, - "description": "Invalid service request input." - }, - "401": { - "description": "Authentication required." + "description": "Invalid request." }, "403": { - "description": "Machine management permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management is required." }, "404": { - "description": "Service request was not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event resource not found." }, "409": { "content": { @@ -12261,15 +12684,15 @@ } } }, - "description": "Service workflow conflict." + "description": "Event state conflict." } } } }, - "/api/v1/admin/machine-service/requests/{id}/files/finalize": { + "/api/v1/admin/events/{id}/feedback-survey/templates/": { "post": { - "operationId": "api_v1_admin_machine_service_requests_files_finalize_create", - "summary": "Finalize a service attachment upload", + "operationId": "api_v1_admin_events_feedback_survey_templates_create", + "summary": "Apply a feedback template before any response", "parameters": [ { "in": "path", @@ -12281,23 +12704,23 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ServiceFileFinalize" + "$ref": "#/components/schemas/FeedbackTemplate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ServiceFileFinalize" + "$ref": "#/components/schemas/FeedbackTemplate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ServiceFileFinalize" + "$ref": "#/components/schemas/FeedbackTemplate" } } }, @@ -12309,11 +12732,11 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ServiceFileFinalizeResponse" + "$ref": "#/components/schemas/FeedbackSurvey" } } }, @@ -12327,16 +12750,37 @@ } } }, - "description": "Invalid attachment input." + "description": "" }, "401": { - "description": "Authentication required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "Machine management permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Service request or file was not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "409": { "content": { @@ -12346,18 +12790,25 @@ } } }, - "description": "Attachment conflict." + "description": "" }, "503": { - "description": "Private storage is unavailable." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/machine-service/requests/{id}/files/presign": { + "/api/v1/admin/events/{id}/image": { "post": { - "operationId": "api_v1_admin_machine_service_requests_files_presign_create", - "summary": "Create a service attachment upload URL", + "operationId": "api_v1_admin_events_image_create", + "summary": "Create an event image upload URL", "parameters": [ { "in": "path", @@ -12369,23 +12820,23 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ServiceFilePresign" + "$ref": "#/components/schemas/PublicImageUploadRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ServiceFilePresign" + "$ref": "#/components/schemas/PublicImageUploadRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ServiceFilePresign" + "$ref": "#/components/schemas/PublicImageUploadRequest" } } }, @@ -12401,51 +12852,29 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ServiceFilePresignResponse" + "$ref": "#/components/schemas/PublicImageUploadResponse" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid attachment input." - }, - "401": { - "description": "Authentication required." + "description": "Invalid image upload request." }, "403": { - "description": "Machine management permission required." + "description": "Event management access is required." }, "404": { - "description": "Service request or file was not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Attachment conflict." + "description": "Event not found." }, "503": { - "description": "Private storage is unavailable." + "description": "Public image storage is unavailable." } } - } - }, - "/api/v1/admin/machine-service/requests/{id}/reject": { - "post": { - "operationId": "api_v1_admin_machine_service_requests_reject_create", - "summary": "Reject a machine service request", + }, + "put": { + "operationId": "api_v1_admin_events_image_update", + "summary": "Attach an uploaded image to an event", "parameters": [ { "in": "path", @@ -12457,23 +12886,23 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ServiceReject" + "$ref": "#/components/schemas/PublicImageAttachRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ServiceReject" + "$ref": "#/components/schemas/PublicImageAttachRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ServiceReject" + "$ref": "#/components/schemas/PublicImageAttachRequest" } } }, @@ -12489,48 +12918,29 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceRequest" + "$ref": "#/components/schemas/EventAdmin" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid service request input." - }, - "401": { - "description": "Authentication required." + "description": "Invalid image object key or size." }, "403": { - "description": "Machine management permission required." + "description": "Event management access is required." }, "404": { - "description": "Service request was not found." + "description": "Event not found." }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Service workflow conflict." + "503": { + "description": "Public image storage is unavailable." } } - } - }, - "/api/v1/admin/machine-service/requests/{id}/reprint": { - "post": { - "operationId": "api_v1_admin_machine_service_requests_reprint_create", - "summary": "Create a printer reprint", + }, + "delete": { + "operationId": "api_v1_admin_events_image_destroy", + "summary": "Clear an event image", "parameters": [ { "in": "path", @@ -12542,7 +12952,7 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], "security": [ { @@ -12554,48 +12964,24 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceRequest" + "$ref": "#/components/schemas/EventAdmin" } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid service request input." - }, - "401": { - "description": "Authentication required." - }, "403": { - "description": "Machine management permission required." + "description": "Event management access is required." }, "404": { - "description": "Service request was not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Service workflow conflict." + "description": "Event not found." } } } }, - "/api/v1/admin/machine-service/requests/{id}/start": { - "post": { - "operationId": "api_v1_admin_machine_service_requests_start_create", - "summary": "Start machine service work", + "/api/v1/admin/events/{id}/invites/": { + "get": { + "operationId": "api_v1_admin_events_invites_retrieve", "parameters": [ { "in": "path", @@ -12604,30 +12990,18 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } } ], "tags": [ - "Admin machine service" + "Admin events" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServiceStart" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ServiceStart" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ServiceStart" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -12638,7 +13012,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceRequest" + "$ref": "#/components/schemas/InviteList" } } }, @@ -12648,20 +13022,42 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": {} } } }, - "description": "Invalid service request input." + "description": "Invalid field values, disabled events module or disabled feature." }, "401": { - "description": "Authentication required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "Machine management permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Service request was not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "409": { "content": { @@ -12671,39 +13067,33 @@ } } }, - "description": "Service workflow conflict." - } - } - } - }, - "/api/v1/admin/machines/{id}": { - "get": { - "operationId": "api_v1_admin_machines_retrieve", - "summary": "Retrieve a machine", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "" + }, + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Machine" + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -12711,9 +13101,8 @@ } } }, - "patch": { - "operationId": "api_v1_admin_machines_partial_update", - "summary": "Update a machine", + "post": { + "operationId": "api_v1_admin_events_invites_create", "parameters": [ { "in": "path", @@ -12725,23 +13114,23 @@ } ], "tags": [ - "Admin machines" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedMachine" + "$ref": "#/components/schemas/InviteInput" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedMachine" + "$ref": "#/components/schemas/InviteInput" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedMachine" + "$ref": "#/components/schemas/InviteInput" } } } @@ -12752,114 +13141,104 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Machine" + "$ref": "#/components/schemas/Invite" } } }, "description": "" }, "400": { - "description": "Invalid machine details." - } - } - } - }, - "/api/v1/admin/machines/{id}/consumable-candidates": { - "get": { - "operationId": "api_v1_admin_machines_consumable_candidates_list", - "summary": "List inventory products eligible as count consumables", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "Invalid field values, disabled events module or disabled feature." + }, + "401": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ConsumableCandidate" - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, "403": { - "description": "Machine operation is not permitted." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Machine not found." - } - } - } - }, - "/api/v1/admin/machines/{id}/consumables": { - "get": { - "operationId": "api_v1_admin_machines_consumables_list", - "summary": "List machine consumables", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "" + }, + "409": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineConsumable" - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "403": { - "description": "Machine operation is not permitted." + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, - "404": { - "description": "Machine not found." + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } - }, + } + }, + "/api/v1/admin/events/{id}/invites/bulk/": { "post": { - "operationId": "api_v1_admin_machines_consumables_create", - "summary": "Link a count or grams consumable to a machine", + "operationId": "api_v1_admin_events_invites_bulk_create", + "description": "Requires manage_invitations and events.invites. At most 200 email-bound invites; mail contains event navigation and no admission credential.", "parameters": [ { "in": "path", @@ -12871,23 +13250,23 @@ } ], "tags": [ - "Admin machines" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LinkMachineConsumable" + "$ref": "#/components/schemas/BulkInviteInput" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/LinkMachineConsumable" + "$ref": "#/components/schemas/BulkInviteInput" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/LinkMachineConsumable" + "$ref": "#/components/schemas/BulkInviteInput" } } }, @@ -12903,83 +13282,101 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineConsumable" + "$ref": "#/components/schemas/BulkInviteResult" } } }, "description": "" }, "400": { - "description": "Invalid consumable." + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid field values, disabled events module or disabled feature." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "Machine management is not permitted." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Machine not found." - } - } - } - }, - "/api/v1/admin/machines/{id}/consumables/{cid}": { - "delete": { - "operationId": "api_v1_admin_machines_consumables_destroy", - "summary": "Unlink a machine consumable", - "parameters": [ - { - "in": "path", - "name": "cid", - "schema": { - "type": "integer" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true + "description": "" }, - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "204": { - "description": "No response body" + "description": "" }, - "400": { - "description": "Consumable is not linked." + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, - "403": { - "description": "Machine management is not permitted." + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, - "404": { - "description": "Machine not found." + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/machines/{id}/consumables/{cid}/log": { - "post": { - "operationId": "api_v1_admin_machines_consumables_log_create", - "summary": "Log machine consumable usage", + "/api/v1/admin/events/{id}/organizers/": { + "put": { + "operationId": "api_v1_admin_events_organizers_update", + "summary": "Replace an event's organization organizers", "parameters": [ - { - "in": "path", - "name": "cid", - "schema": { - "type": "integer" - }, - "required": true - }, { "in": "path", "name": "id", @@ -12990,23 +13387,23 @@ } ], "tags": [ - "Admin machines" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LogMachineConsumption" + "$ref": "#/components/schemas/EventOrganizerReplace" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/LogMachineConsumption" + "$ref": "#/components/schemas/EventOrganizerReplace" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/LogMachineConsumption" + "$ref": "#/components/schemas/EventOrganizerReplace" } } }, @@ -13022,28 +13419,54 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineConsumable" + "$ref": "#/components/schemas/EventOrganizerList" } } }, "description": "" }, "400": { - "description": "Invalid quantity or insufficient stock." + "description": "Invalid or unavailable organization." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "Machine operation is not permitted." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Machine not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "description": "Concurrent event state conflict." } } } }, - "/api/v1/admin/machines/{id}/documents": { + "/api/v1/admin/events/{id}/promo-codes/": { "get": { - "operationId": "api_v1_admin_machines_documents_list", - "summary": "List machine documents", + "operationId": "api_v1_admin_events_promo_codes_retrieve", "parameters": [ { "in": "path", @@ -13052,10 +13475,17 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } } ], "tags": [ - "Admin machines" + "Admin events" ], "security": [ { @@ -13067,164 +13497,88 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineDocument" - } + "$ref": "#/components/schemas/PromoList" } } }, "description": "" - } - } - }, - "post": { - "operationId": "api_v1_admin_machines_documents_create", - "summary": "Finalize a machine document upload", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DocumentFinalize" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/DocumentFinalize" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/DocumentFinalize" - } - } }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "201": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineDocument" + "type": "object", + "additionalProperties": {} } } }, - "description": "" + "description": "Invalid field values, disabled events module or disabled feature." }, - "400": { - "description": "Invalid machine document." - }, - "503": { - "description": "Machine document storage is unavailable." - } - } - } - }, - "/api/v1/admin/machines/{id}/documents/presign": { - "post": { - "operationId": "api_v1_admin_machines_documents_presign_create", - "summary": "Create a machine document upload URL", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DocumentPresign" + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/DocumentPresign" + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/DocumentPresign" + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } - } + }, + "description": "" }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "201": { - "description": "Machine document upload details." + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, - "400": { - "description": "Invalid document upload request." + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "503": { - "description": "Machine document storage is unavailable." - } - } - } - }, - "/api/v1/admin/machines/{id}/error-logs": { - "get": { - "operationId": "api_v1_admin_machines_error_logs_list", - "summary": "List machine error logs", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineErrorLog" - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -13233,8 +13587,7 @@ } }, "post": { - "operationId": "api_v1_admin_machines_error_logs_create", - "summary": "Log a machine error", + "operationId": "api_v1_admin_events_promo_codes_create", "parameters": [ { "in": "path", @@ -13246,27 +13599,26 @@ } ], "tags": [ - "Admin machines" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LogError" + "$ref": "#/components/schemas/PromoInput" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/LogError" + "$ref": "#/components/schemas/PromoInput" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/LogError" + "$ref": "#/components/schemas/PromoInput" } } - }, - "required": true + } }, "security": [ { @@ -13278,88 +13630,100 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineErrorLog" + "$ref": "#/components/schemas/Promo" } } }, "description": "" }, "400": { - "description": "Invalid error log." - } - } - } - }, - "/api/v1/admin/machines/{id}/image": { - "post": { - "operationId": "api_v1_admin_machines_image_create", - "summary": "Create a machine image upload URL", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" + "description": "Invalid field values, disabled events module or disabled feature." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } - } + }, + "description": "" }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "201": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicImageUploadResponse" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "400": { - "description": "Invalid image upload request." + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, - "403": { - "description": "Machine management access is required." + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, - "404": { - "description": "Machine not found." + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "503": { - "description": "Public image storage is unavailable." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } - }, - "put": { - "operationId": "api_v1_admin_machines_image_update", - "summary": "Attach an uploaded image to a machine", + } + }, + "/api/v1/admin/events/{id}/publish/": { + "post": { + "operationId": "api_v1_admin_events_publish_create", + "summary": "Publish an event", "parameters": [ { "in": "path", @@ -13371,27 +13735,26 @@ } ], "tags": [ - "Admin machines" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" + "$ref": "#/components/schemas/EventWebsitePublishInput" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" + "$ref": "#/components/schemas/EventWebsitePublishInput" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" + "$ref": "#/components/schemas/EventWebsitePublishInput" } } - }, - "required": true + } }, "security": [ { @@ -13403,71 +13766,70 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Machine" + "$ref": "#/components/schemas/EventAdmin" } } }, "description": "" }, "400": { - "description": "Invalid image object key or size." - }, - "403": { - "description": "Machine management access is required." - }, - "404": { - "description": "Machine not found." - }, - "503": { - "description": "Public image storage is unavailable." - } - } - }, - "delete": { - "operationId": "api_v1_admin_machines_image_destroy", - "summary": "Clear a machine image", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "Invalid event details." + }, + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Machine" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, "403": { - "description": "Machine management access is required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Machine not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event state or capacity conflict." } } } }, - "/api/v1/admin/machines/{id}/operator-candidates": { + "/api/v1/admin/events/{id}/registrations/": { "get": { - "operationId": "api_v1_admin_machines_operator_candidates_list", - "summary": "List active members eligible for machine operator assignment", + "operationId": "api_v1_admin_events_registrations_retrieve", + "summary": "List event registrations", "parameters": [ { "in": "path", @@ -13479,7 +13841,7 @@ } ], "tags": [ - "Admin machines" + "Admin events" ], "security": [ { @@ -13491,65 +13853,68 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/OperatorCandidate" - } + "$ref": "#/components/schemas/EventRegistrationListResponse" } } }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Unexpected request body or events module disabled." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required." + }, "403": { - "description": "Operator delegation is not permitted." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management is required." }, "404": { - "description": "Machine not found." - } - } - } - }, - "/api/v1/admin/machines/{id}/operators": { - "get": { - "operationId": "api_v1_admin_machines_operators_list", - "summary": "List machine operators", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "Registration not found." + }, + "409": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineOperator" - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Event state or capacity conflict." } } }, "post": { - "operationId": "api_v1_admin_machines_operators_create", - "summary": "Assign a machine operator", + "operationId": "api_v1_admin_events_registrations_create", + "summary": "Register a member for an event", "parameters": [ { "in": "path", @@ -13561,23 +13926,23 @@ } ], "tags": [ - "Admin machines" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssignOperator" + "$ref": "#/components/schemas/EventStaffRegistration" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/AssignOperator" + "$ref": "#/components/schemas/EventStaffRegistration" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/AssignOperator" + "$ref": "#/components/schemas/EventStaffRegistration" } } }, @@ -13593,22 +13958,68 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineOperator" + "$ref": "#/components/schemas/EventRegistrationAdmin" } } }, "description": "" }, "400": { - "description": "Invalid operator assignment." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid registration." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event state or capacity conflict." } } } }, - "/api/v1/admin/machines/{id}/operators/{user_pk}": { - "patch": { - "operationId": "api_v1_admin_machines_operators_partial_update", - "summary": "Update a machine operator", + "/api/v1/admin/events/{id}/staff-grants/": { + "get": { + "operationId": "api_v1_admin_events_staff_grants_retrieve", "parameters": [ { "in": "path", @@ -13619,36 +14030,16 @@ "required": true }, { - "in": "path", - "name": "user_pk", + "in": "query", + "name": "page", "schema": { "type": "integer" - }, - "required": true + } } ], "tags": [ - "Admin machines" + "Admin events" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedAssignOperator" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedAssignOperator" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedAssignOperator" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -13659,155 +14050,98 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineOperator" + "$ref": "#/components/schemas/StaffGrantList" } } }, "description": "" }, "400": { - "description": "Invalid operator assignment." - } - } - }, - "delete": { - "operationId": "api_v1_admin_machines_operators_destroy", - "summary": "Remove a machine operator", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true + "description": "" }, - { - "in": "path", - "name": "user_pk", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "204": { - "description": "No response body" - } - } - } - }, - "/api/v1/admin/machines/{id}/publicity": { - "get": { - "operationId": "api_v1_admin_machines_publicity_retrieve", - "summary": "Preview a machine public listing", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicMachine" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" - } - } - }, - "patch": { - "operationId": "api_v1_admin_machines_publicity_partial_update", - "summary": "Set machine public visibility", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedMachinePublicity" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedMachinePublicity" + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedMachinePublicity" - } - } - } - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "" + }, + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicMachine" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "400": { - "description": "Invalid publicity setting." + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, - "403": { - "description": "MANAGE_MACHINES is required." + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, - "404": { - "description": "Machine not found." + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/machines/{id}/retire": { - "post": { - "operationId": "api_v1_admin_machines_retire_create", - "summary": "Retire a machine", + "/api/v1/admin/events/{id}/staff-invites/": { + "get": { + "operationId": "api_v1_admin_events_staff_invites_retrieve", "parameters": [ { "in": "path", @@ -13816,10 +14150,17 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } } ], "tags": [ - "Admin machines" + "Admin events" ], "security": [ { @@ -13831,141 +14172,87 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Machine" + "$ref": "#/components/schemas/StaffGrantInviteList" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/machines/{id}/set-status": { - "post": { - "operationId": "api_v1_admin_machines_set_status_create", - "summary": "Set a machine status", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetStatus" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/SetStatus" + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/SetStatus" + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } - } + }, + "description": "" }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Machine" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "400": { - "description": "Invalid machine status." - } - } - } - }, - "/api/v1/admin/machines/{id}/unretire": { - "post": { - "operationId": "api_v1_admin_machines_unretire_create", - "summary": "Reactivate a retired machine", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "" + }, + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Machine" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/machines/{id}/usage": { - "get": { - "operationId": "api_v1_admin_machines_usage_list", - "summary": "List machine usage entries", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "" + }, + "503": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineUsageEntry" - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -13974,8 +14261,7 @@ } }, "post": { - "operationId": "api_v1_admin_machines_usage_create", - "summary": "Log machine usage", + "operationId": "api_v1_admin_events_staff_invites_create", "parameters": [ { "in": "path", @@ -13987,23 +14273,23 @@ } ], "tags": [ - "Admin machines" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LogUsage" + "$ref": "#/components/schemas/StaffGrantInviteInput" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/LogUsage" + "$ref": "#/components/schemas/StaffGrantInviteInput" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/LogUsage" + "$ref": "#/components/schemas/StaffGrantInviteInput" } } }, @@ -14019,46 +14305,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineUsageEntry" + "$ref": "#/components/schemas/StaffGrantInviteCreated" } } }, "description": "" }, "400": { - "description": "Invalid usage entry." - } - } - } - }, - "/api/v1/admin/machines/{id}/warranty": { - "get": { - "operationId": "api_v1_admin_machines_warranty_retrieve", - "summary": "Retrieve warranty details for a machine", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin warranty" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Warranty" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -14093,64 +14350,18 @@ } }, "description": "" - } - } - }, - "put": { - "operationId": "api_v1_admin_machines_warranty_update", - "summary": "Create or update warranty details for a machine", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin warranty" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WarrantyUpsert" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/WarrantyUpsert" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/WarrantyUpsert" - } - } - } - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + }, + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Warranty" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "400": { - "description": "Invalid warranty details." - }, - "401": { + "423": { "content": { "application/json": { "schema": { @@ -14160,7 +14371,7 @@ }, "description": "" }, - "403": { + "429": { "content": { "application/json": { "schema": { @@ -14170,7 +14381,7 @@ }, "description": "" }, - "404": { + "503": { "content": { "application/json": { "schema": { @@ -14183,10 +14394,9 @@ } } }, - "/api/v1/admin/machines/documents/{id}": { - "delete": { - "operationId": "api_v1_admin_machines_documents_destroy", - "summary": "Delete a machine document", + "/api/v1/admin/events/{id}/ticket-types/": { + "get": { + "operationId": "api_v1_admin_events_ticket_types_retrieve", "parameters": [ { "in": "path", @@ -14195,39 +14405,17 @@ "type": "integer" }, "required": true - } - ], - "tags": [ - "Admin machines" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "204": { - "description": "No response body" - } - } - } - }, - "/api/v1/admin/machines/documents/{id}/url": { - "get": { - "operationId": "api_v1_admin_machines_documents_url_retrieve", - "summary": "Create a signed machine document view URL", - "parameters": [ + }, { - "in": "path", - "name": "id", + "in": "query", + "name": "page", "schema": { "type": "integer" - }, - "required": true + } } ], "tags": [ - "Admin machines" + "Admin events" ], "security": [ { @@ -14236,41 +14424,27 @@ ], "responses": { "200": { - "description": "Signed machine document URL." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketList" + } + } + }, + "description": "" }, - "503": { - "description": "Machine document storage is unavailable." - } - } - } - }, - "/api/v1/admin/maintenance/log-documents/{id}/": { - "delete": { - "operationId": "api_v1_admin_maintenance_log_documents_destroy", - "summary": "Delete a maintenance document", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } }, - "required": true - } - ], - "tags": [ - "Admin maintenance" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "204": { - "description": "No response body" + "description": "Invalid field values, disabled events module or disabled feature." }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -14278,7 +14452,7 @@ } } }, - "description": "Invalid request." + "description": "" }, "403": { "content": { @@ -14288,7 +14462,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -14298,7 +14472,7 @@ } } }, - "description": "Not found." + "description": "" }, "409": { "content": { @@ -14308,7 +14482,27 @@ } } }, - "description": "Workflow conflict." + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "503": { "content": { @@ -14318,15 +14512,12 @@ } } }, - "description": "Service unavailable." + "description": "" } } - } - }, - "/api/v1/admin/maintenance/log-documents/{id}/url/": { - "get": { - "operationId": "api_v1_admin_maintenance_log_documents_url_retrieve", - "summary": "Create a private maintenance document URL", + }, + "post": { + "operationId": "api_v1_admin_events_ticket_types_create", "parameters": [ { "in": "path", @@ -14338,25 +14529,56 @@ } ], "tags": [ - "Admin maintenance" + "Admin events" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/TicketInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/TicketInput" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceDocumentUrl" + "$ref": "#/components/schemas/Ticket" } } }, "description": "" }, "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid field values, disabled events module or disabled feature." + }, + "401": { "content": { "application/json": { "schema": { @@ -14364,7 +14586,7 @@ } } }, - "description": "Invalid request." + "description": "" }, "403": { "content": { @@ -14374,7 +14596,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -14384,7 +14606,7 @@ } } }, - "description": "Not found." + "description": "" }, "409": { "content": { @@ -14394,7 +14616,27 @@ } } }, - "description": "Workflow conflict." + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "503": { "content": { @@ -14404,15 +14646,14 @@ } } }, - "description": "Service unavailable." + "description": "" } } } }, - "/api/v1/admin/maintenance/logs/{id}/documents/": { - "post": { - "operationId": "api_v1_admin_maintenance_logs_documents_create", - "summary": "Finalize a maintenance document upload", + "/api/v1/admin/events/{id}/website/": { + "get": { + "operationId": "api_v1_admin_events_website_retrieve", "parameters": [ { "in": "path", @@ -14424,39 +14665,19 @@ } ], "tags": [ - "Admin maintenance" + "Event website" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MaintenanceDocumentFinalize" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/MaintenanceDocumentFinalize" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/MaintenanceDocumentFinalize" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceLogDocument" + "$ref": "#/components/schemas/Website" } } }, @@ -14470,7 +14691,17 @@ } } }, - "description": "Invalid request." + "description": "Invalid content, stale revision, disabled feature or terminal event." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Sign in required." }, "403": { "content": { @@ -14480,7 +14711,7 @@ } } }, - "description": "Permission denied." + "description": "Missing event permission or wrong staff origin." }, "404": { "content": { @@ -14490,9 +14721,9 @@ } } }, - "description": "Not found." + "description": "Unknown or out-of-scope event/page/image." }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -14500,7 +14731,7 @@ } } }, - "description": "Workflow conflict." + "description": "Rate limit exceeded." }, "503": { "content": { @@ -14510,15 +14741,12 @@ } } }, - "description": "Service unavailable." + "description": "Private image storage is unavailable." } } - } - }, - "/api/v1/admin/maintenance/logs/{id}/documents/presign/": { - "post": { - "operationId": "api_v1_admin_maintenance_logs_documents_presign_create", - "summary": "Create a maintenance document upload URL", + }, + "put": { + "operationId": "api_v1_admin_events_website_update", "parameters": [ { "in": "path", @@ -14530,23 +14758,23 @@ } ], "tags": [ - "Admin maintenance" + "Event website" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceDocumentPresign" + "$ref": "#/components/schemas/WebsiteContentInput" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MaintenanceDocumentPresign" + "$ref": "#/components/schemas/WebsiteContentInput" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MaintenanceDocumentPresign" + "$ref": "#/components/schemas/WebsiteContentInput" } } }, @@ -14562,7 +14790,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceDocumentPresignResponse" + "$ref": "#/components/schemas/Website" } } }, @@ -14576,7 +14804,17 @@ } } }, - "description": "Invalid request." + "description": "Invalid content, stale revision, disabled feature or terminal event." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Sign in required." }, "403": { "content": { @@ -14586,7 +14824,7 @@ } } }, - "description": "Permission denied." + "description": "Missing event permission or wrong staff origin." }, "404": { "content": { @@ -14596,9 +14834,9 @@ } } }, - "description": "Not found." + "description": "Unknown or out-of-scope event/page/image." }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -14606,7 +14844,7 @@ } } }, - "description": "Workflow conflict." + "description": "Rate limit exceeded." }, "503": { "content": { @@ -14616,15 +14854,14 @@ } } }, - "description": "Service unavailable." + "description": "Private image storage is unavailable." } } } }, - "/api/v1/admin/maintenance/schedules/{id}/": { - "patch": { - "operationId": "api_v1_admin_maintenance_schedules_partial_update", - "summary": "Update a maintenance schedule", + "/api/v1/admin/events/{id}/website/assets/": { + "post": { + "operationId": "api_v1_admin_events_website_assets_create", "parameters": [ { "in": "path", @@ -14636,26 +14873,17 @@ } ], "tags": [ - "Admin maintenance" + "Event website" ], "requestBody": { "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedMaintenanceScheduleWrite" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedMaintenanceScheduleWrite" - } - }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedMaintenanceScheduleWrite" + "$ref": "#/components/schemas/WebsiteAssetInput" } } - } + }, + "required": true }, "security": [ { @@ -14663,42 +14891,91 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceSchedule" + "$ref": "#/components/schemas/WebsiteAsset" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid content, stale revision, disabled feature or terminal event." }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Sign in required." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Missing event permission or wrong staff origin." }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Unknown or out-of-scope event/page/image." }, - "409": { - "description": "Conflicts with the current state." + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." }, - "423": { - "description": "Makerspace writes are frozen for tenant migration." + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Private image storage is unavailable." } } } }, - "/api/v1/admin/maintenance/schedules/{id}/deactivate/": { - "post": { - "operationId": "api_v1_admin_maintenance_schedules_deactivate_create", - "summary": "Deactivate a maintenance schedule", + "/api/v1/admin/events/{id}/website/assets/{asset_id}": { + "get": { + "operationId": "api_v1_admin_events_website_assets_retrieve", "parameters": [ + { + "in": "path", + "name": "asset_id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "id", @@ -14709,7 +14986,7 @@ } ], "tags": [ - "Admin maintenance" + "Event website" ], "security": [ { @@ -14721,87 +14998,97 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceSchedule" + "type": "string", + "format": "binary" } } }, - "description": "" + "description": "Private WebP image; no-store." }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid content, stale revision, disabled feature or terminal event." }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Sign in required." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Missing event permission or wrong staff origin." }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Unknown or out-of-scope event/page/image." }, - "409": { - "description": "Conflicts with the current state." + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." }, - "423": { - "description": "Makerspace writes are frozen for tenant migration." + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Private image storage is unavailable." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/accepted-requests": { - "get": { - "operationId": "api_v1_admin_makerspace_accepted_requests_list", - "summary": "List accepted requests awaiting issue", + }, + "delete": { + "operationId": "api_v1_admin_events_website_assets_destroy", "parameters": [ - { - "name": "id", - "in": "query", - "required": false, - "description": "Select one result within the authorized list.", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 9223372036854775807 - } - }, { "in": "path", - "name": "makerspace_id", + "name": "asset_id", "schema": { "type": "integer" }, "required": true }, { - "name": "ordering", - "required": false, - "in": "query", - "description": "Which field to use when ordering the results.", - "schema": { - "type": "string" - } - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", + "in": "path", + "name": "id", "schema": { "type": "integer" - } - }, - { - "name": "search", - "required": false, - "in": "query", - "description": "A search term (requested-for, requester name/email).", - "schema": { - "type": "string" - } + }, + "required": true } ], "tags": [ - "Admin requests" + "Event website" ], "security": [ { @@ -14809,17 +15096,20 @@ } ], "responses": { - "200": { + "204": { + "description": "No response body" + }, + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedAdminRequestList" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Invalid content, stale revision, disabled feature or terminal event." }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -14827,9 +15117,9 @@ } } }, - "description": "Invalid request." + "description": "Sign in required." }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -14837,9 +15127,9 @@ } } }, - "description": "Authentication required." + "description": "Missing event permission or wrong staff origin." }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -14847,9 +15137,9 @@ } } }, - "description": "Permission denied." + "description": "Unknown or out-of-scope event/page/image." }, - "404": { + "429": { "content": { "application/json": { "schema": { @@ -14857,19 +15147,28 @@ } } }, - "description": "Not found." + "description": "Rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Private image storage is unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/accountability": { - "get": { - "operationId": "api_v1_admin_makerspace_accountability_retrieve", - "summary": "Requester accountability dashboard", + "/api/v1/admin/events/{id}/website/release/": { + "post": { + "operationId": "api_v1_admin_events_website_release_create", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -14877,8 +15176,28 @@ } ], "tags": [ - "Analytics" + "Event website" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebsiteReleaseInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/WebsiteReleaseInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/WebsiteReleaseInput" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -14889,8 +15208,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/Website" } } }, @@ -14900,100 +15218,109 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid report request." + "description": "Invalid content, stale revision, disabled feature or terminal event." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "Sign in required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Permission denied." + "description": "Missing event permission or wrong staff origin." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace or report not found." + "description": "Unknown or out-of-scope event/page/image." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Private image storage is unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/active-loans": { - "get": { - "operationId": "api_v1_admin_makerspace_active_loans_list", - "summary": "List active loans awaiting return", + "/api/v1/admin/events/{id}/website/sessions/{slug}/delay/": { + "post": { + "operationId": "api_v1_admin_events_website_sessions_delay_create", "parameters": [ { + "in": "path", "name": "id", - "in": "query", - "required": false, - "description": "Select one result within the authorized list.", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 9223372036854775807 - } - }, - { - "in": "path", - "name": "makerspace_id", "schema": { "type": "integer" }, "required": true }, { - "name": "ordering", - "required": false, - "in": "query", - "description": "Which field to use when ordering the results.", - "schema": { - "type": "string" - } - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } - }, - { - "name": "search", - "required": false, - "in": "query", - "description": "A search term (requested-for, requester name/email).", + "in": "path", + "name": "slug", "schema": { "type": "string" - } + }, + "required": true } ], "tags": [ - "Admin requests" + "Event website" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebsiteDelayInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/WebsiteDelayInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/WebsiteDelayInput" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -15004,7 +15331,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedAdminRequestList" + "$ref": "#/components/schemas/Website" } } }, @@ -15018,7 +15345,7 @@ } } }, - "description": "Invalid request." + "description": "Invalid content, stale revision, disabled feature or terminal event." }, "401": { "content": { @@ -15028,7 +15355,7 @@ } } }, - "description": "Authentication required." + "description": "Sign in required." }, "403": { "content": { @@ -15038,7 +15365,7 @@ } } }, - "description": "Permission denied." + "description": "Missing event permission or wrong staff origin." }, "404": { "content": { @@ -15048,109 +15375,82 @@ } } }, - "description": "Not found." + "description": "Unknown or out-of-scope event/page/image." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Private image storage is unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/{report_key}": { + "/api/v1/admin/evidence/{id}": { "get": { - "operationId": "api_v1_admin_makerspace_analytics_retrieve", - "summary": "Get analytics report", + "operationId": "api_v1_admin_evidence_retrieve", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "grain", - "schema": { - "type": "string", - "enum": [ - "day", - "month", - "week" - ] - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, + } + ], + "tags": [ + "api" + ], + "security": [ { - "in": "query", - "name": "months", - "schema": { - "type": "integer" + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EvidenceGetResponse" + } + } }, - "description": "Retention anniversary in months (1-120); default 3, 6 and 12." - }, + "description": "" + } + } + } + }, + "/api/v1/admin/inventory/{id}": { + "get": { + "operationId": "api_v1_admin_inventory_retrieve", + "summary": "Retrieve or update inventory product", + "parameters": [ { "in": "path", - "name": "report_key", + "name": "id", "schema": { - "type": "string" + "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" - ] - } } ], "tags": [ - "Analytics" + "Admin inventory" ], "security": [ { @@ -15162,146 +15462,61 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/InventoryProductAdmin" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Invalid report request." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Authentication required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Permission denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Makerspace or report not found." + "description": "Not found, not visible to this actor, or the surface is not enabled here." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/active-loans": { - "get": { - "operationId": "api_v1_admin_makerspace_analytics_active_loans_retrieve", - "summary": "Get analytics report", + }, + "patch": { + "operationId": "api_v1_admin_inventory_partial_update", + "summary": "Retrieve or update inventory product", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "grain", - "schema": { - "type": "string", - "enum": [ - "day", - "month", - "week" - ] - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "months", - "schema": { - "type": "integer" - }, - "description": "Retention anniversary in months (1-120); default 3, 6 and 12." - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" - ] - } } ], "tags": [ - "Analytics" + "Admin inventory" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedInventoryProductAdminUpdate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedInventoryProductAdminUpdate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedInventoryProductAdminUpdate" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -15312,90 +15527,94 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/InventoryProductAdminUpdate" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Invalid report request." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Authentication required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." + } + } + } + }, + "/api/v1/admin/inventory/{id}/adjust-quantity": { + "post": { + "operationId": "api_v1_admin_inventory_adjust_quantity_create", + "summary": "Adjust inventory quantity buckets", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin inventory" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InventoryQuantityAdjustment" } }, - "description": "Permission denied." + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/InventoryQuantityAdjustment" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/InventoryQuantityAdjustment" + } + } }, - "404": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/InventoryProductAdmin" } } }, - "description": "Makerspace or report not found." + "description": "" } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/booking-utilization": { + "/api/v1/admin/inventory/{id}/chain-of-custody": { "get": { - "operationId": "api_v1_admin_makerspace_analytics_booking_utilization_retrieve", - "summary": "Get analytics report", + "operationId": "api_v1_admin_inventory_chain_of_custody_retrieve", + "summary": "Read-only chain of custody for one inventory item", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "grain", - "schema": { - "type": "string", - "enum": [ - "day", - "month", - "week" - ] - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -15403,54 +15622,15 @@ }, { "in": "query", - "name": "months", + "name": "limit", "schema": { "type": "integer" }, - "description": "Retention anniversary in months (1-120); default 3, 6 and 12." - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" - ] - } + "description": "Maximum history events to return. Defaults to 200; capped at 500." } ], "tags": [ - "Analytics" + "Admin inventory" ], "security": [ { @@ -15462,7 +15642,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/InventoryChainOfCustodyResponse" } } }, @@ -15472,17 +15652,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid report request." + "description": "Invalid request." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -15492,7 +15672,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -15502,105 +15682,161 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace or report not found." - } + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." + } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/damaged-lost": { - "get": { - "operationId": "api_v1_admin_makerspace_analytics_damaged_lost_retrieve", - "summary": "Get analytics report", + "/api/v1/admin/inventory/{id}/image": { + "post": { + "operationId": "api_v1_admin_inventory_image_create", + "summary": "Create an inventory product image upload URL", "parameters": [ { - "in": "query", - "name": "end", + "in": "path", + "name": "id", "schema": { - "type": "string", - "format": "date" + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin inventory" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadRequest" + } } }, + "required": true + }, + "security": [ { - "in": "query", - "name": "grain", - "schema": { - "type": "string", - "enum": [ - "day", - "month", - "week" - ] - } + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadResponse" + } + } + }, + "description": "" }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } + "400": { + "description": "Invalid image upload request." }, + "503": { + "description": "Public image storage is unavailable." + } + } + }, + "put": { + "operationId": "api_v1_admin_inventory_image_update", + "summary": "Attach an uploaded image to an inventory product", + "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "months", - "schema": { - "type": "integer" + } + ], + "tags": [ + "Admin inventory" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicImageAttachRequest" + } }, - "description": "Retention anniversary in months (1-120); default 3, 6 and 12." - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicImageAttachRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicImageAttachRequest" + } } }, + "required": true + }, + "security": [ { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InventoryProductAdmin" + } + } + }, + "description": "" + }, + "400": { + "description": "Invalid image object key or size." }, + "503": { + "description": "Public image storage is unavailable." + } + } + }, + "delete": { + "operationId": "api_v1_admin_inventory_image_destroy", + "summary": "Clear an inventory product image", + "parameters": [ { - "in": "query", - "name": "subject_type", + "in": "path", + "name": "id", "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" - ] - } + "type": "integer" + }, + "required": true } ], "tags": [ - "Analytics" + "Admin inventory" ], "security": [ { @@ -15612,145 +15848,261 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/InventoryProductAdmin" } } }, "description": "" - }, - "400": { + } + } + } + }, + "/api/v1/admin/inventory/{id}/lending-history": { + "get": { + "operationId": "api_v1_admin_inventory_lending_history_retrieve", + "summary": "Per-item lending history (last borrower + last 3 lends)", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin inventory" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/LendingHistoryResponse" } } }, - "description": "Invalid report request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } + "description": "" + } + } + } + }, + "/api/v1/admin/inventory/{id}/needs-fix": { + "post": { + "operationId": "api_v1_admin_inventory_needs_fix_create", + "description": "Move units onto the shelf, back to available, or out of inventory.", + "summary": "Move inventory units to, from, or out of the to-be-fixed shelf", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin inventory" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NeedsFixAction" } }, - "description": "Authentication required." + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/NeedsFixAction" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/NeedsFixAction" + } + } }, - "403": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/InventoryProductAdmin" } } }, - "description": "Permission denied." - }, - "404": { + "description": "" + } + } + } + }, + "/api/v1/admin/inventory/{id}/qr-history": { + "get": { + "operationId": "api_v1_admin_inventory_qr_history_retrieve", + "summary": "List QR scan history for an inventory product", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin inventory" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/ProductQrHistory" } } }, - "description": "Makerspace or report not found." + "description": "" } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/damaged-missing": { + "/api/v1/admin/inventory/{product_pk}/assets": { "get": { - "operationId": "api_v1_admin_makerspace_analytics_damaged_missing_retrieve", - "summary": "Get analytics report", + "operationId": "api_v1_admin_inventory_assets_list", + "summary": "List individual assets for an inventory product", "parameters": [ { + "name": "page", + "required": false, "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "grain", + "description": "A page number within the paginated result set.", "schema": { - "type": "string", - "enum": [ - "day", - "month", - "week" - ] + "type": "integer" } }, { + "name": "page_size", + "required": false, "in": "query", - "name": "limit", + "description": "Number of results to return per page.", "schema": { "type": "integer" } }, { "in": "path", - "name": "makerspace_id", + "name": "product_pk", "schema": { "type": "integer" }, "required": true - }, + } + ], + "tags": [ + "Admin inventory" + ], + "security": [ { - "in": "query", - "name": "months", - "schema": { - "type": "integer" + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedInventoryAssetAdminList" + } + } }, - "description": "Retention anniversary in months (1-120); default 3, 6 and 12." - }, + "description": "" + } + } + } + }, + "/api/v1/admin/inventory/needs-fix": { + "get": { + "operationId": "api_v1_admin_inventory_needs_fix_list", + "description": "The to-be-fixed shelf: products that currently have units awaiting repair.", + "parameters": [ { + "name": "page", + "required": false, "in": "query", - "name": "start", + "description": "A page number within the paginated result set.", "schema": { - "type": "string", - "format": "date" + "type": "integer" } }, { + "name": "page_size", + "required": false, "in": "query", - "name": "status", + "description": "Number of results to return per page.", "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] + "type": "integer" } - }, + } + ], + "tags": [ + "api" + ], + "security": [ { - "in": "query", - "name": "subject_type", + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedInventoryProductAdminList" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/admin/invitation-requests/{id}/decline": { + "post": { + "operationId": "api_v1_admin_invitation_requests_decline_create", + "description": "Requires manage_members in this makerspace. Staff role administration and membership plan definitions require manage_makerspace.", + "summary": "Decline an invitation request", + "parameters": [ + { + "in": "path", + "name": "id", "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" - ] - } + "type": "integer" + }, + "required": true } ], "tags": [ - "Analytics" + "Admin memberships" ], "security": [ { @@ -15762,7 +16114,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/InvitationRequest" } } }, @@ -15772,136 +16124,93 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid report request." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace or report not found." + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/event-attendance": { - "get": { - "operationId": "api_v1_admin_makerspace_analytics_event_attendance_retrieve", - "summary": "Get analytics report", + "/api/v1/admin/invitation-requests/{id}/invite": { + "post": { + "operationId": "api_v1_admin_invitation_requests_invite_create", + "description": "Requires manage_members in this makerspace. Staff role administration and membership plan definitions require manage_makerspace.", + "summary": "Turn an invitation request into a membership invitation", "parameters": [ { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "grain", - "schema": { - "type": "string", - "enum": [ - "day", - "month", - "week" - ] - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, - { - "in": "path", - "name": "makerspace_id", + "in": "path", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "months", - "schema": { - "type": "integer" - }, - "description": "Retention anniversary in months (1-120); default 3, 6 and 12." - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" - ] - } } ], "tags": [ - "Analytics" + "Admin memberships" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoleId" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/RoleId" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/RoleId" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -15912,7 +16221,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/InvitationRequest" } } }, @@ -15922,135 +16231,72 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid report request." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace or report not found." + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/fablab-health": { + "/api/v1/admin/kiosk-replays/{id}/": { "get": { - "operationId": "api_v1_admin_makerspace_analytics_fablab_health_retrieve", - "summary": "Get analytics report", + "operationId": "api_v1_admin_kiosk_replays_retrieve", + "summary": "Inspect one current-action-scoped failed handover", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "grain", - "schema": { - "type": "string", - "enum": [ - "day", - "month", - "week" - ] - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "required": true - }, - { - "in": "query", - "name": "months", - "schema": { - "type": "integer" - }, - "description": "Retention anniversary in months (1-120); default 3, 6 and 12." - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" - ] - } } ], "tags": [ - "Analytics" + "Admin kiosk recovery" ], "security": [ { @@ -16062,7 +16308,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/ReplayRecovery" } } }, @@ -16072,17 +16318,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid report request." + "description": "Invalid filter/resolution or disabled required module." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -16092,116 +16338,84 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Permission denied." + "description": "Account, current action or origin refused." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace or report not found." + "description": "Failure is unavailable in your scope." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Resolution/replacement conflict." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Tenant source gate closed." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/machine-usage": { - "get": { - "operationId": "api_v1_admin_makerspace_analytics_machine_usage_retrieve", - "summary": "Get analytics report", + "/api/v1/admin/kiosk-replays/{id}/resolve/": { + "post": { + "operationId": "api_v1_admin_kiosk_replays_resolve_create", + "description": "Acknowledgement records no physical change. Replacement links only a fresh succeeded replay for the same known subject; this endpoint never executes handovers.", + "summary": "Append an idempotent review of a failed handover", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "grain", - "schema": { - "type": "string", - "enum": [ - "day", - "month", - "week" - ] - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "required": true - }, - { - "in": "query", - "name": "months", - "schema": { - "type": "integer" - }, - "description": "Retention anniversary in months (1-120); default 3, 6 and 12." - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" - ] - } } ], "tags": [ - "Analytics" + "Admin kiosk recovery" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplayResolution" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ReplayResolution" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ReplayResolution" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -16212,7 +16426,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/ReplayRecovery" } } }, @@ -16222,17 +16436,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid report request." + "description": "Invalid filter/resolution or disabled required module." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -16242,115 +16456,61 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Permission denied." + "description": "Account, current action or origin refused." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace or report not found." + "description": "Failure is unavailable in your scope." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Resolution/replacement conflict." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Tenant source gate closed." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/maintenance-activity": { + "/api/v1/admin/kiosk-stations/{id}/": { "get": { - "operationId": "api_v1_admin_makerspace_analytics_maintenance_activity_retrieve", - "summary": "Get analytics report", + "operationId": "api_v1_admin_kiosk_stations_retrieve", + "summary": "Read one kiosk Station without its PIN", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "grain", - "schema": { - "type": "string", - "enum": [ - "day", - "month", - "week" - ] - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "months", - "schema": { - "type": "integer" - }, - "description": "Retention anniversary in months (1-120); default 3, 6 and 12." - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" - ] - } } ], "tags": [ - "Analytics" + "Admin kiosk stations" ], "security": [ { @@ -16362,7 +16522,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/KioskStation" } } }, @@ -16372,135 +16532,79 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid report request." + "description": "Feature disabled or invalid request." }, - "401": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "Makerspace access or step-up denied." }, - "403": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Permission denied." + "description": "Station not found." }, - "404": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace or report not found." + "description": "Rotate the PIN instead of revealing." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Request rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Credential secrets are unavailable." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/member-activity": { - "get": { - "operationId": "api_v1_admin_makerspace_analytics_member_activity_retrieve", - "summary": "Get analytics report", + }, + "delete": { + "operationId": "api_v1_admin_kiosk_stations_destroy", + "summary": "Disable a kiosk Station and revoke its leases", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "grain", - "schema": { - "type": "string", - "enum": [ - "day", - "month", - "week" - ] - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "months", - "schema": { - "type": "integer" - }, - "description": "Retention anniversary in months (1-120); default 3, 6 and 12." - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" - ] - } } ], "tags": [ - "Analytics" + "Admin kiosk stations" ], "security": [ { @@ -16512,7 +16616,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/KioskStation" } } }, @@ -16522,136 +16626,102 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid report request." + "description": "Feature disabled or invalid request." }, - "401": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "Makerspace access or step-up denied." }, - "403": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Permission denied." + "description": "Station not found." }, - "404": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace or report not found." + "description": "Rotate the PIN instead of revealing." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Request rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Credential secrets are unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/most-lent": { - "get": { - "operationId": "api_v1_admin_makerspace_analytics_most_lent_retrieve", - "summary": "Get analytics report", + "/api/v1/admin/kiosk-stations/{id}/reveal/": { + "post": { + "operationId": "api_v1_admin_kiosk_stations_reveal_create", + "summary": "Reveal a kiosk Station PIN after password step-up", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "grain", - "schema": { - "type": "string", - "enum": [ - "day", - "month", - "week" - ] - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "months", - "schema": { - "type": "integer" - }, - "description": "Retention anniversary in months (1-120); default 3, 6 and 12." - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" - ] - } } ], "tags": [ - "Analytics" + "Admin kiosk stations" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KioskStationReveal" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/KioskStationReveal" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/KioskStationReveal" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -16662,7 +16732,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/KioskStationRevealResponse" } } }, @@ -16672,135 +16742,81 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid report request." + "description": "Feature disabled or invalid request." }, - "401": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "Makerspace access or step-up denied." }, - "403": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Permission denied." + "description": "Station not found." }, - "404": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace or report not found." + "description": "Rotate the PIN instead of revealing." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Request rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Credential secrets are unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/payment-reconciliation": { - "get": { - "operationId": "api_v1_admin_makerspace_analytics_payment_reconciliation_retrieve", - "summary": "Get analytics report", + "/api/v1/admin/kiosk-stations/{id}/rotate/": { + "post": { + "operationId": "api_v1_admin_kiosk_stations_rotate_create", + "summary": "Rotate a kiosk Station PIN (re-enables it)", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "grain", - "schema": { - "type": "string", - "enum": [ - "day", - "month", - "week" - ] - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "months", - "schema": { - "type": "integer" - }, - "description": "Retention anniversary in months (1-120); default 3, 6 and 12." - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" - ] - } } ], "tags": [ - "Analytics" + "Admin kiosk stations" ], "security": [ { @@ -16812,7 +16828,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/KioskStationSecret" } } }, @@ -16822,135 +16838,143 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid report request." + "description": "Feature disabled or invalid request." }, - "401": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "Makerspace access or step-up denied." }, - "403": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Permission denied." + "description": "Station not found." }, - "404": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace or report not found." + "description": "Rotate the PIN instead of revealing." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Request rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Credential secrets are unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/qr-scans": { + "/api/v1/admin/ledger": { "get": { - "operationId": "api_v1_admin_makerspace_analytics_qr_scans_retrieve", - "summary": "Get analytics report", + "operationId": "api_v1_admin_ledger_retrieve", + "summary": "List outstanding inventory loans across all makerspaces", "parameters": [ { "in": "query", - "name": "end", + "name": "makerspace", "schema": { - "type": "string", - "format": "date" + "type": "integer" } }, { "in": "query", - "name": "grain", + "name": "overdue", "schema": { - "type": "string", - "enum": [ - "day", - "month", - "week" - ] + "type": "boolean" } }, { "in": "query", - "name": "limit", + "name": "page", "schema": { "type": "integer" } }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - }, { "in": "query", - "name": "months", + "name": "page_size", "schema": { "type": "integer" - }, - "description": "Retention anniversary in months (1-120); default 3, 6 and 12." + } }, { "in": "query", - "name": "start", + "name": "search", "schema": { - "type": "string", - "format": "date" + "type": "string" } }, { "in": "query", - "name": "status", + "name": "sort", "schema": { "type": "string", "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" + "-due", + "-holder", + "-item_name", + "-makerspace_id", + "-quantity", + "-since", + "-source", + "due", + "holder", + "item_name", + "makerspace_id", + "quantity", + "since", + "source" ] } }, { "in": "query", - "name": "subject_type", + "name": "source", "schema": { "type": "string", "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" + "direct", + "reviewed", + "self_checkout" ] } } ], "tags": [ - "Analytics" + "Ledger" ], "security": [ { @@ -16962,145 +16986,90 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/LedgerResponse" } } }, "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Invalid report request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Permission denied." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Makerspace or report not found." - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/recently-added": { - "get": { - "operationId": "api_v1_admin_makerspace_analytics_recently_added_retrieve", - "summary": "Get analytics report", - "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "grain", - "schema": { - "type": "string", - "enum": [ - "day", - "month", - "week" - ] - } + } + } + } + }, + "/api/v1/admin/ledger/export": { + "get": { + "operationId": "api_v1_admin_ledger_export_retrieve", + "summary": "Export outstanding inventory loans across all makerspaces", + "parameters": [ + { + "in": "query", + "name": "format", + "schema": { + "type": "string", + "enum": [ + "csv", + "xlsx" + ] + } }, { "in": "query", - "name": "limit", + "name": "makerspace", "schema": { "type": "integer" } }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - }, { "in": "query", - "name": "months", + "name": "overdue", "schema": { - "type": "integer" - }, - "description": "Retention anniversary in months (1-120); default 3, 6 and 12." + "type": "boolean" + } }, { "in": "query", - "name": "start", + "name": "search", "schema": { - "type": "string", - "format": "date" + "type": "string" } }, { "in": "query", - "name": "status", + "name": "sort", "schema": { "type": "string", "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" + "-due", + "-holder", + "-item_name", + "-makerspace_id", + "-quantity", + "-since", + "-source", + "due", + "holder", + "item_name", + "makerspace_id", + "quantity", + "since", + "source" ] } }, { "in": "query", - "name": "subject_type", + "name": "source", "schema": { "type": "string", "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" + "direct", + "reviewed", + "self_checkout" ] } } ], "tags": [ - "Analytics" + "Ledger" ], "security": [ { @@ -17110,61 +17079,27 @@ "responses": { "200": { "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Invalid report request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { + "text/csv": { "schema": { - "$ref": "#/components/schemas/ReportError" + "type": "string" } - } - }, - "description": "Permission denied." - }, - "404": { - "content": { - "application/json": { + }, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { "schema": { - "$ref": "#/components/schemas/ReportError" + "type": "string", + "format": "binary" } } }, - "description": "Makerspace or report not found." + "description": "" } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/returns": { + "/api/v1/admin/machine-service-report": { "get": { - "operationId": "api_v1_admin_makerspace_analytics_returns_retrieve", - "summary": "Get analytics report", + "operationId": "api_v1_admin_machine_service_report_retrieve", + "summary": "Retrieve aggregate machine-service report", "parameters": [ { "in": "query", @@ -17176,39 +17111,11 @@ }, { "in": "query", - "name": "grain", - "schema": { - "type": "string", - "enum": [ - "day", - "month", - "week" - ] - } - }, - { - "in": "query", - "name": "limit", + "name": "machine_type", "schema": { - "type": "integer" + "type": "string" } }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "query", - "name": "months", - "schema": { - "type": "integer" - }, - "description": "Retention anniversary in months (1-120); default 3, 6 and 12." - }, { "in": "query", "name": "start", @@ -17216,41 +17123,10 @@ "type": "string", "format": "date" } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" - ] - } } ], "tags": [ - "Analytics" + "Admin machine service" ], "security": [ { @@ -17262,7 +17138,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/MachineServiceReportResponse" } } }, @@ -17272,136 +17148,147 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid report request." + "description": "Invalid request." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Authentication required." + "description": "Authentication credentials were not provided." }, "403": { + "description": "Machine management permission required." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/admin/machine-service/consumable-pools/{id}": { + "get": { + "operationId": "api_v1_admin_machine_service_consumable_pools_retrieve", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machine service" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/PrinterPool" } } }, - "description": "Permission denied." - }, - "404": { + "description": "" + } + } + }, + "patch": { + "operationId": "api_v1_admin_machine_service_consumable_pools_partial_update", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machine service" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedPrinterPoolVisibility" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedPrinterPoolVisibility" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedPrinterPoolVisibility" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/PrinterPool" } } }, - "description": "Makerspace or report not found." + "description": "" } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/summary": { - "get": { - "operationId": "api_v1_admin_makerspace_analytics_summary_retrieve", - "summary": "Get analytics report", + "/api/v1/admin/machine-service/consumable-pools/{id}/adjustments": { + "post": { + "operationId": "api_v1_admin_machine_service_consumable_pools_adjustments_create", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "grain", - "schema": { - "type": "string", - "enum": [ - "day", - "month", - "week" - ] - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "months", - "schema": { - "type": "integer" - }, - "description": "Retention anniversary in months (1-120); default 3, 6 and 12." - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" - ] - } } ], "tags": [ - "Analytics" + "Admin machine service" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrinterPoolCorrection" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PrinterPoolCorrection" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PrinterPoolCorrection" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -17412,145 +17299,92 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/PrinterPool" } } }, "description": "" + } + } + } + }, + "/api/v1/admin/machine-service/files/{id}": { + "delete": { + "operationId": "api_v1_admin_machine_service_files_destroy", + "summary": "Delete a staged service attachment", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machine service" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" }, "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid report request." + "description": "Invalid attachment input." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, "description": "Authentication required." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Permission denied." + "description": "Machine management permission required." }, "404": { + "description": "Service request or file was not found." + }, + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace or report not found." + "description": "Attachment conflict." + }, + "503": { + "description": "Private storage is unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/taken-items": { + "/api/v1/admin/machine-service/files/{id}/url": { "get": { - "operationId": "api_v1_admin_makerspace_analytics_taken_items_retrieve", - "summary": "Get analytics report", + "operationId": "api_v1_admin_machine_service_files_url_retrieve", + "summary": "Create a signed service attachment URL", "parameters": [ { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "grain", - "schema": { - "type": "string", - "enum": [ - "day", - "month", - "week" - ] - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, - { - "in": "path", - "name": "makerspace_id", + "in": "path", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "months", - "schema": { - "type": "integer" - }, - "description": "Retention anniversary in months (1-120); default 3, 6 and 12." - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" - ] - } } ], "tags": [ - "Analytics" + "Admin machine service" ], "security": [ { @@ -17562,7 +17396,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/ServiceFileUrl" } } }, @@ -17572,136 +17406,74 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid report request." + "description": "Invalid attachment input." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, "description": "Authentication required." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Permission denied." + "description": "Machine management permission required." }, "404": { + "description": "Service request or file was not found." + }, + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace or report not found." + "description": "Attachment conflict." + }, + "503": { + "description": "Private storage is unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/top-borrowers": { - "get": { - "operationId": "api_v1_admin_makerspace_analytics_top_borrowers_retrieve", - "summary": "Get analytics report", + "/api/v1/admin/machine-service/payments/{id}/mark-offline": { + "post": { + "operationId": "api_v1_admin_machine_service_payments_mark_offline_create", + "summary": "Mark a machine-service payment paid offline", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "grain", - "schema": { - "type": "string", - "enum": [ - "day", - "month", - "week" - ] - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "months", - "schema": { - "type": "integer" - }, - "description": "Retention anniversary in months (1-120); default 3, 6 and 12." - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" - ] - } } ], "tags": [ - "Analytics" + "Payments" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentOffline" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PaymentOffline" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PaymentOffline" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -17712,7 +17484,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/StaffPayment" } } }, @@ -17722,17 +17494,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid report request." + "description": "Missing or invalid settlement receipt." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -17742,7 +17514,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -17752,23 +17524,33 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace or report not found." + "description": "Payment not found or out of machine scope." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "`payment_terminal`: already reconciled." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/api-client-scopes": { - "get": { - "operationId": "api_v1_admin_makerspace_api_client_scopes_retrieve", - "summary": "List API-client scope grant options", + "/api/v1/admin/machine-service/payments/{id}/waive": { + "post": { + "operationId": "api_v1_admin_machine_service_payments_waive_create", + "summary": "Waive a machine-service payment", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -17776,7 +17558,7 @@ } ], "tags": [ - "API clients" + "Payments" ], "security": [ { @@ -17788,7 +17570,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiClientScopeCatalogResponse" + "$ref": "#/components/schemas/StaffPayment" } } }, @@ -17802,7 +17584,7 @@ } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { @@ -17812,7 +17594,7 @@ } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { @@ -17822,36 +17604,37 @@ } } }, - "description": "" + "description": "Payment not found or out of machine scope." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "`payment_terminal`: already reconciled." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/api-clients": { + "/api/v1/admin/machine-service/requests/{id}": { "get": { - "operationId": "api_v1_admin_makerspace_api_clients_list", - "summary": "List or create makerspace API clients", + "operationId": "api_v1_admin_machine_service_requests_retrieve", + "summary": "Retrieve a machine service request", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } } ], "tags": [ - "API clients" + "Admin machine service" ], "security": [ { @@ -17863,13 +17646,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedApiClientList" + "$ref": "#/components/schemas/MachineServiceRequest" } } }, "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { @@ -17877,19 +17660,18 @@ } } }, - "description": "" + "description": "Invalid service request input." + }, + "401": { + "description": "Authentication required." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Machine management permission required." }, "404": { + "description": "Service request was not found." + }, + "409": { "content": { "application/json": { "schema": { @@ -17897,17 +17679,19 @@ } } }, - "description": "" + "description": "Service workflow conflict." } } - }, + } + }, + "/api/v1/admin/machine-service/requests/{id}/accept": { "post": { - "operationId": "api_v1_admin_makerspace_api_clients_create", - "summary": "List or create makerspace API clients", + "operationId": "api_v1_admin_machine_service_requests_accept_create", + "summary": "Accept a machine service request", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -17915,27 +17699,26 @@ } ], "tags": [ - "API clients" + "Admin machine service" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiClient" + "$ref": "#/components/schemas/ServiceAccept" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ApiClient" + "$ref": "#/components/schemas/ServiceAccept" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ApiClient" + "$ref": "#/components/schemas/ServiceAccept" } } - }, - "required": true + } }, "security": [ { @@ -17943,17 +17726,17 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiClientCreateResponse" + "$ref": "#/components/schemas/MachineServiceRequest" } } }, "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { @@ -17961,19 +17744,18 @@ } } }, - "description": "" + "description": "Invalid service request input." + }, + "401": { + "description": "Authentication required." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Machine management permission required." }, "404": { + "description": "Service request was not found." + }, + "409": { "content": { "application/json": { "schema": { @@ -17981,19 +17763,19 @@ } } }, - "description": "" + "description": "Service workflow conflict." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/api-settings": { - "get": { - "operationId": "api_v1_admin_makerspace_api_settings_retrieve", - "summary": "Retrieve API integration settings", + "/api/v1/admin/machine-service/requests/{id}/collect": { + "post": { + "operationId": "api_v1_admin_machine_service_requests_collect_create", + "summary": "Mark a machine service request collected", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -18001,7 +17783,7 @@ } ], "tags": [ - "API clients" + "Admin machine service" ], "security": [ { @@ -18013,30 +17795,52 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiIntegrationSettings" + "$ref": "#/components/schemas/MachineServiceRequest" } } }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid service request input." + }, "401": { "description": "Authentication required." }, "403": { - "description": "The actor lacks manage_makerspace here." + "description": "Machine management permission required." }, "404": { - "description": "Makerspace not found, archived, not active, or outside the actor's scope." + "description": "Service request was not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Service workflow conflict." } } - }, - "patch": { - "operationId": "api_v1_admin_makerspace_api_settings_partial_update", - "summary": "Update API integration settings", + } + }, + "/api/v1/admin/machine-service/requests/{id}/complete": { + "post": { + "operationId": "api_v1_admin_machine_service_requests_complete_create", + "summary": "Complete machine service work", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -18044,26 +17848,27 @@ } ], "tags": [ - "API clients" + "Admin machine service" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedApiIntegrationSettings" + "$ref": "#/components/schemas/ServiceComplete" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedApiIntegrationSettings" + "$ref": "#/components/schemas/ServiceComplete" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedApiIntegrationSettings" + "$ref": "#/components/schemas/ServiceComplete" } } - } + }, + "required": true }, "security": [ { @@ -18075,35 +17880,52 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiIntegrationSettings" + "$ref": "#/components/schemas/MachineServiceRequest" } } }, "description": "" }, "400": { - "description": "Field validation errors, keyed by field." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid service request input." }, "401": { "description": "Authentication required." }, "403": { - "description": "The actor lacks manage_makerspace here." + "description": "Machine management permission required." }, "404": { - "description": "Makerspace not found, archived, not active, or outside the actor's scope." + "description": "Service request was not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Service workflow conflict." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients": { - "get": { - "operationId": "api_v1_admin_makerspace_archive_recipients_list", - "summary": "List archive recipients for a makerspace", + "/api/v1/admin/machine-service/requests/{id}/fail": { + "post": { + "operationId": "api_v1_admin_machine_service_requests_fail_create", + "summary": "Mark machine service work failed", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -18111,8 +17933,28 @@ } ], "tags": [ - "Backup recipients" + "Admin machine service" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceFail" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ServiceFail" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ServiceFail" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -18123,10 +17965,7 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ArchiveRecipient" - } + "$ref": "#/components/schemas/MachineServiceRequest" } } }, @@ -18136,52 +17975,42 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "The recipient or lifecycle request is invalid." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The recipient does not exist in this makerspace." + "description": "Invalid service request input." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "Authentication is required." + "description": "Authentication required." }, "403": { + "description": "Machine management permission required." + }, + "404": { + "description": "Service request was not found." + }, + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Manage-makerspace permission is required." + "description": "Service workflow conflict." } } - }, + } + }, + "/api/v1/admin/machine-service/requests/{id}/files/finalize": { "post": { - "operationId": "api_v1_admin_makerspace_archive_recipients_create", - "description": "The decrypted challenge is a 32-byte nonce exchanged as canonical, unpadded base64url. Only the SHA-256 digest of the raw 32 nonce bytes is persisted.", - "summary": "Enroll an archive recipient and issue a custody challenge", + "operationId": "api_v1_admin_machine_service_requests_files_finalize_create", + "summary": "Finalize a service attachment upload", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -18189,23 +18018,23 @@ } ], "tags": [ - "Backup recipients" + "Admin machine service" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientCreate" + "$ref": "#/components/schemas/ServiceFileFinalize" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientCreate" + "$ref": "#/components/schemas/ServiceFileFinalize" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientCreate" + "$ref": "#/components/schemas/ServiceFileFinalize" } } }, @@ -18221,7 +18050,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientChallenge" + "$ref": "#/components/schemas/ServiceFileFinalizeResponse" } } }, @@ -18231,69 +18060,41 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The recipient or lifecycle request is invalid." + "description": "Invalid attachment input." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "description": "Machine management permission required." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "The recipient does not exist in this makerspace." + "description": "Service request or file was not found." }, "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The recipient or lifecycle request is invalid." + "description": "Attachment conflict." }, "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "The age challenge could not be encrypted." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "Authentication is required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "Manage-makerspace permission is required." + "description": "Private storage is unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients/{id}/compromise": { + "/api/v1/admin/machine-service/requests/{id}/files/presign": { "post": { - "operationId": "api_v1_admin_makerspace_archive_recipients_compromise_create", - "summary": "Mark an archive recipient as compromised", + "operationId": "api_v1_admin_machine_service_requests_files_presign_create", + "summary": "Create a service attachment upload URL", "parameters": [ { "in": "path", @@ -18302,30 +18103,42 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Backup recipients" + "Admin machine service" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceFilePresign" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ServiceFilePresign" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ServiceFilePresign" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipient" + "$ref": "#/components/schemas/ServiceFilePresignResponse" } } }, @@ -18335,49 +18148,41 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "The recipient or lifecycle request is invalid." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The recipient does not exist in this makerspace." + "description": "Invalid attachment input." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "Authentication is required." + "description": "Authentication required." }, "403": { + "description": "Machine management permission required." + }, + "404": { + "description": "Service request or file was not found." + }, + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Manage-makerspace permission is required." + "description": "Attachment conflict." + }, + "503": { + "description": "Private storage is unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients/{id}/reactivate": { + "/api/v1/admin/machine-service/requests/{id}/reject": { "post": { - "operationId": "api_v1_admin_makerspace_archive_recipients_reactivate_create", - "summary": "Reactivate a revoked archive recipient", + "operationId": "api_v1_admin_machine_service_requests_reject_create", + "summary": "Reject a machine service request", "parameters": [ { "in": "path", @@ -18386,19 +18191,31 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Backup recipients" + "Admin machine service" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceReject" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ServiceReject" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ServiceReject" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -18409,7 +18226,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipient" + "$ref": "#/components/schemas/MachineServiceRequest" } } }, @@ -18419,50 +18236,38 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "The recipient or lifecycle request is invalid." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The recipient does not exist in this makerspace." + "description": "Invalid service request input." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "Authentication is required." + "description": "Authentication required." }, "403": { + "description": "Machine management permission required." + }, + "404": { + "description": "Service request was not found." + }, + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Manage-makerspace permission is required." + "description": "Service workflow conflict." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients/{id}/reissue-challenge": { + "/api/v1/admin/machine-service/requests/{id}/reprint": { "post": { - "operationId": "api_v1_admin_makerspace_archive_recipients_reissue_challenge_create", - "description": "The decrypted 32-byte nonce is exchanged as canonical, unpadded base64url. The prior challenge stops verifying when this request commits.", - "summary": "Reissue an archive-recipient custody challenge", + "operationId": "api_v1_admin_machine_service_requests_reprint_create", + "summary": "Create a printer reprint", "parameters": [ { "in": "path", @@ -18471,18 +18276,10 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Backup recipients" + "Admin machine service" ], "security": [ { @@ -18494,7 +18291,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientChallenge" + "$ref": "#/components/schemas/MachineServiceRequest" } } }, @@ -18504,59 +18301,38 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "The recipient or lifecycle request is invalid." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "The recipient does not exist in this makerspace." - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The age challenge could not be encrypted." + "description": "Invalid service request input." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "Authentication is required." + "description": "Authentication required." }, "403": { + "description": "Machine management permission required." + }, + "404": { + "description": "Service request was not found." + }, + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Manage-makerspace permission is required." + "description": "Service workflow conflict." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients/{id}/revoke": { + "/api/v1/admin/machine-service/requests/{id}/start": { "post": { - "operationId": "api_v1_admin_makerspace_archive_recipients_revoke_create", - "summary": "Revoke an archive recipient", + "operationId": "api_v1_admin_machine_service_requests_start_create", + "summary": "Start machine service work", "parameters": [ { "in": "path", @@ -18565,19 +18341,30 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Backup recipients" + "Admin machine service" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceStart" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ServiceStart" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ServiceStart" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -18588,7 +18375,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipient" + "$ref": "#/components/schemas/MachineServiceRequest" } } }, @@ -18598,50 +18385,38 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "The recipient or lifecycle request is invalid." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The recipient does not exist in this makerspace." + "description": "Invalid service request input." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "Authentication is required." + "description": "Authentication required." }, "403": { + "description": "Machine management permission required." + }, + "404": { + "description": "Service request was not found." + }, + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Manage-makerspace permission is required." + "description": "Service workflow conflict." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients/{id}/verify": { - "post": { - "operationId": "api_v1_admin_makerspace_archive_recipients_verify_create", - "description": "Submit the decrypted nonce as canonical unpadded base64url. It must decode to exactly 32 bytes; padding and non-canonical forms are refused.", - "summary": "Verify possession of an archive recipient", + "/api/v1/admin/machines/{id}": { + "get": { + "operationId": "api_v1_admin_machines_retrieve", + "summary": "Retrieve a machine", "parameters": [ { "in": "path", @@ -18650,10 +18425,36 @@ "type": "integer" }, "required": true - }, + } + ], + "tags": [ + "Admin machines" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Machine" + } + } + }, + "description": "" + } + } + }, + "patch": { + "operationId": "api_v1_admin_machines_partial_update", + "summary": "Update a machine", + "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -18661,27 +18462,26 @@ } ], "tags": [ - "Backup recipients" + "Admin machines" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientVerify" + "$ref": "#/components/schemas/PatchedMachine" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientVerify" + "$ref": "#/components/schemas/PatchedMachine" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientVerify" + "$ref": "#/components/schemas/PatchedMachine" } } - }, - "required": true + } }, "security": [ { @@ -18693,83 +18493,26 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipient" + "$ref": "#/components/schemas/Machine" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "The recipient or lifecycle request is invalid." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "The recipient or lifecycle request is invalid." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "The recipient does not exist in this makerspace." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "Verification attempts for this recipient are throttled." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "Authentication is required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "Manage-makerspace permission is required." + "description": "Invalid machine details." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/archive-requests": { + "/api/v1/admin/machines/{id}/consumable-candidates": { "get": { - "operationId": "api_v1_admin_makerspace_archive_requests_list", - "summary": "List archive requests for a makerspace", + "operationId": "api_v1_admin_machines_consumable_candidates_list", + "summary": "List inventory products eligible as count consumables", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -18777,7 +18520,7 @@ } ], "tags": [ - "Admin makerspaces" + "Admin machines" ], "security": [ { @@ -18791,7 +18534,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/MakerspaceArchiveRequest" + "$ref": "#/components/schemas/ConsumableCandidate" } } } @@ -18799,34 +18542,22 @@ "description": "" }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRequestError" - } - } - }, - "description": "" + "description": "Machine operation is not permitted." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRequestError" - } - } - }, - "description": "" + "description": "Machine not found." } } - }, - "post": { - "operationId": "api_v1_admin_makerspace_archive_requests_create", - "summary": "Request makerspace archival", + } + }, + "/api/v1/admin/machines/{id}/consumables": { + "get": { + "operationId": "api_v1_admin_machines_consumables_list", + "summary": "List machine consumables", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -18834,91 +18565,38 @@ } ], "tags": [ - "Admin makerspaces" + "Admin machines" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MakerspaceArchiveRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/MakerspaceArchiveRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/MakerspaceArchiveRequest" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MakerspaceArchiveRequest" + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineConsumable" + } } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRequestValidationError" - } - } - }, - "description": "The reason was blank or too long." - }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRequestError" - } - } - }, - "description": "" + "description": "Machine operation is not permitted." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRequestError" - } - } - }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRequestError" - } - } - }, - "description": "" + "description": "Machine not found." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/archive-requests/{id}/withdraw": { + }, "post": { - "operationId": "api_v1_admin_makerspace_archive_requests_withdraw_create", - "summary": "Withdraw a pending makerspace archive request", + "operationId": "api_v1_admin_machines_consumables_create", + "summary": "Link a count or grams consumable to a machine", "parameters": [ { "in": "path", @@ -18927,92 +18605,83 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Admin makerspaces" + "Admin machines" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LinkMachineConsumable" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/LinkMachineConsumable" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/LinkMachineConsumable" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MakerspaceArchiveRequest" + "$ref": "#/components/schemas/MachineConsumable" } } }, "description": "" }, + "400": { + "description": "Invalid consumable." + }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRequestError" - } - } - }, - "description": "" + "description": "Machine management is not permitted." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRequestError" - } - } - }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRequestError" - } - } - }, - "description": "" + "description": "Machine not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/audit-verifications": { - "get": { - "operationId": "api_v1_admin_makerspace_audit_verifications_retrieve", - "summary": "List audit verification runs for one makerspace", + "/api/v1/admin/machines/{id}/consumables/{cid}": { + "delete": { + "operationId": "api_v1_admin_machines_consumables_destroy", + "summary": "Unlink a machine consumable", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "cid", "schema": { "type": "integer" }, "required": true }, { - "in": "query", - "name": "page", + "in": "path", + "name": "id", "schema": { "type": "integer" }, - "description": "Page number (24 runs per page)." + "required": true } ], "tags": [ - "Audit verification" + "Admin machines" ], "security": [ { @@ -19020,66 +18689,37 @@ } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VerificationRunPage" - } - } - }, - "description": "" + "204": { + "description": "No response body" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuditVerificationBadRequest" - } - } - }, - "description": "Invalid filters, `narrow_audit_range` or `audit_verification_scope_too_large`." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuditVerificationDetailError" - } - } - }, - "description": "Not authenticated." + "description": "Consumable is not linked." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuditVerificationDetailError" - } - } - }, - "description": "Not active staff." + "description": "Machine management is not permitted." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuditVerificationDetailError" - } - } - }, - "description": "Makerspace or run not in the caller's VIEW_AUDIT scope." + "description": "Machine not found." } } - }, + } + }, + "/api/v1/admin/machines/{id}/consumables/{cid}/log": { "post": { - "operationId": "api_v1_admin_makerspace_audit_verifications_create", - "description": "Always asynchronous: 202 with the new or deduplicated run (`deduplicated`), then poll `status_url`. One active run per makerspace (409 `audit_verification_busy`); at most 10,000 selected rows and bounded expanded work (400); throttled per user (429).", - "summary": "Request asynchronous verification of the filtered audit rows", + "operationId": "api_v1_admin_machines_consumables_log_create", + "summary": "Log machine consumable usage", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "cid", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "id", "schema": { "type": "integer" }, @@ -19087,26 +18727,27 @@ } ], "tags": [ - "Audit verification" + "Admin machines" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerificationRequest" + "$ref": "#/components/schemas/LogMachineConsumption" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/VerificationRequest" + "$ref": "#/components/schemas/LogMachineConsumption" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/VerificationRequest" + "$ref": "#/components/schemas/LogMachineConsumption" } } - } + }, + "required": true }, "security": [ { @@ -19114,95 +18755,36 @@ } ], "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerificationAccepted" + "$ref": "#/components/schemas/MachineConsumable" } } }, "description": "" }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuditVerificationError" - } - } - }, - "description": "`audit_verification_busy`." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuditVerificationDetailError" - } - } - }, - "description": "Throttled." - }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuditVerificationBadRequest" - } - } - }, - "description": "Invalid filters, `narrow_audit_range` or `audit_verification_scope_too_large`." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuditVerificationDetailError" - } - } - }, - "description": "Not authenticated." + "description": "Invalid quantity or insufficient stock." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuditVerificationDetailError" - } - } - }, - "description": "Not active staff." + "description": "Machine operation is not permitted." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuditVerificationDetailError" - } - } - }, - "description": "Makerspace or run not in the caller's VIEW_AUDIT scope." + "description": "Machine not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/audit-verifications/{run_id}": { + "/api/v1/admin/machines/{id}/documents": { "get": { - "operationId": "api_v1_admin_makerspace_audit_verifications_retrieve_2", - "summary": "Read one audit verification run's latest state and coverage", + "operationId": "api_v1_admin_machines_documents_list", + "summary": "List machine documents", "parameters": [ { "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "path", - "name": "run_id", + "name": "id", "schema": { "type": "integer" }, @@ -19210,7 +18792,7 @@ } ], "tags": [ - "Audit verification" + "Admin machines" ], "security": [ { @@ -19222,63 +18804,86 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerificationRun" + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineDocument" + } } } }, "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuditVerificationBadRequest" - } - } + } + } + }, + "post": { + "operationId": "api_v1_admin_machines_documents_create", + "summary": "Finalize a machine document upload", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" }, - "description": "Invalid filters, `narrow_audit_range` or `audit_verification_scope_too_large`." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuditVerificationDetailError" - } + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentFinalize" } }, - "description": "Not authenticated." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuditVerificationDetailError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/DocumentFinalize" } }, - "description": "Not active staff." + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/DocumentFinalize" + } + } }, - "404": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AuditVerificationDetailError" + "$ref": "#/components/schemas/MachineDocument" } } }, - "description": "Makerspace or run not in the caller's VIEW_AUDIT scope." + "description": "" + }, + "400": { + "description": "Invalid machine document." + }, + "503": { + "description": "Machine document storage is unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/backup-custody": { - "get": { - "operationId": "api_v1_admin_makerspace_backup_custody_retrieve", - "summary": "Get the makerspace's archive and tenant-exit custody status", + "/api/v1/admin/machines/{id}/documents/presign": { + "post": { + "operationId": "api_v1_admin_machines_documents_presign_create", + "summary": "Create a machine document upload URL", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -19286,47 +18891,54 @@ } ], "tags": [ - "Backup recipients" + "Admin machines" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentPresign" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/DocumentPresign" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/DocumentPresign" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BackupCustody" - } - } - }, - "description": "" + "201": { + "description": "Machine document upload details." }, "400": { - "description": "The backup module is not installed for this makerspace." - }, - "404": { - "description": "The requested resource does not exist in the actor's scope." - }, - "401": { - "description": "Authentication is required." + "description": "Invalid document upload request." }, - "403": { - "description": "The authenticated actor is not authorized." + "503": { + "description": "Machine document storage is unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/backups": { + "/api/v1/admin/machines/{id}/error-logs": { "get": { - "operationId": "api_v1_admin_makerspace_backups_list", - "summary": "List makerspace backup archives", + "operationId": "api_v1_admin_machines_error_logs_list", + "summary": "List machine error logs", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -19334,7 +18946,7 @@ } ], "tags": [ - "Backup" + "Admin machines" ], "security": [ { @@ -19348,34 +18960,22 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/BackupArchive" + "$ref": "#/components/schemas/MachineErrorLog" } } } }, "description": "" - }, - "400": { - "description": "The backup module is not installed for this makerspace." - }, - "404": { - "description": "The requested resource does not exist in the actor's scope." - }, - "401": { - "description": "Authentication is required." - }, - "403": { - "description": "The authenticated actor is not authorized." } } }, "post": { - "operationId": "api_v1_admin_makerspace_backups_create", - "summary": "Request an age-encrypted makerspace backup", + "operationId": "api_v1_admin_machines_error_logs_create", + "summary": "Log a machine error", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -19383,51 +18983,58 @@ } ], "tags": [ - "Backup" + "Admin machines" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogError" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/LogError" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/LogError" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "202": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BackupArchive" + "$ref": "#/components/schemas/MachineErrorLog" } } }, "description": "" }, "400": { - "description": "The backup module is not installed for this makerspace." - }, - "404": { - "description": "The requested resource does not exist in the actor's scope." - }, - "503": { - "description": "The backup worker is unavailable." - }, - "401": { - "description": "Authentication is required." - }, - "403": { - "description": "The authenticated actor is not authorized." + "description": "Invalid error log." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/billing-settings": { - "get": { - "operationId": "api_v1_admin_makerspace_billing_settings_retrieve", - "description": "Requires manage_makerspace. No payments module or credentials required; billing.reminders is a separate opt-in.", - "summary": "Read billing, receipt and accounting policy", + "/api/v1/admin/machines/{id}/image": { + "post": { + "operationId": "api_v1_admin_machines_image_create", + "summary": "Create a machine image upload URL", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -19435,74 +19042,65 @@ } ], "tags": [ - "Payments" + "Admin machines" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadRequest" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BillingSettings" + "$ref": "#/components/schemas/PublicImageUploadResponse" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BillingPolicyError" - } - } - }, - "description": "Invalid billing policy fields." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Invalid image upload request." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Machine management access is required." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Machine not found." + }, + "503": { + "description": "Public image storage is unavailable." } } }, - "patch": { - "operationId": "api_v1_admin_makerspace_billing_settings_partial_update", - "description": "Requires manage_makerspace. Terms apply only to new charges. Reminders require billing.reminders, notifications and email; no online rail required.", - "summary": "Change billing, receipt and accounting policy", + "put": { + "operationId": "api_v1_admin_machines_image_update", + "summary": "Attach an uploaded image to a machine", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -19510,26 +19108,27 @@ } ], "tags": [ - "Payments" + "Admin machines" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedBillingSettings" + "$ref": "#/components/schemas/PublicImageAttachRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedBillingSettings" + "$ref": "#/components/schemas/PublicImageAttachRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedBillingSettings" + "$ref": "#/components/schemas/PublicImageAttachRequest" } } - } + }, + "required": true }, "security": [ { @@ -19541,80 +19140,128 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BillingSettings" + "$ref": "#/components/schemas/Machine" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BillingPolicyError" - } - } - }, - "description": "Invalid billing policy fields." + "description": "Invalid image object key or size." }, - "401": { + "403": { + "description": "Machine management access is required." + }, + "404": { + "description": "Machine not found." + }, + "503": { + "description": "Public image storage is unavailable." + } + } + }, + "delete": { + "operationId": "api_v1_admin_machines_image_destroy", + "summary": "Clear a machine image", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/Machine" } } }, "description": "" }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Machine management access is required." }, "404": { + "description": "Machine not found." + } + } + } + }, + "/api/v1/admin/machines/{id}/operator-candidates": { + "get": { + "operationId": "api_v1_admin_machines_operator_candidates_list", + "summary": "List active members eligible for machine operator assignment", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "array", + "items": { + "$ref": "#/components/schemas/OperatorCandidate" + } } } }, "description": "" + }, + "403": { + "description": "Operator delegation is not permitted." + }, + "404": { + "description": "Machine not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/categories": { + "/api/v1/admin/machines/{id}/operators": { "get": { - "operationId": "api_v1_admin_makerspace_categories_list", - "summary": "List or create inventory categories", + "operationId": "api_v1_admin_machines_operators_list", + "summary": "List machine operators", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } } ], "tags": [ - "Admin inventory" + "Admin machines" ], "security": [ { @@ -19626,7 +19273,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedCategoryAdminList" + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineOperator" + } } } }, @@ -19635,12 +19285,12 @@ } }, "post": { - "operationId": "api_v1_admin_makerspace_categories_create", - "summary": "List or create inventory categories", + "operationId": "api_v1_admin_machines_operators_create", + "summary": "Assign a machine operator", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -19648,23 +19298,23 @@ } ], "tags": [ - "Admin inventory" + "Admin machines" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CategoryAdmin" + "$ref": "#/components/schemas/AssignOperator" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/CategoryAdmin" + "$ref": "#/components/schemas/AssignOperator" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/CategoryAdmin" + "$ref": "#/components/schemas/AssignOperator" } } }, @@ -19680,40 +19330,62 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CategoryAdmin" + "$ref": "#/components/schemas/MachineOperator" } } }, "description": "" + }, + "400": { + "description": "Invalid operator assignment." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/certification-types": { - "get": { - "operationId": "api_v1_admin_makerspace_certification_types_retrieve", - "summary": "List certification types in a makerspace", + "/api/v1/admin/machines/{id}/operators/{user_pk}": { + "patch": { + "operationId": "api_v1_admin_machines_operators_partial_update", + "summary": "Update a machine operator", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true }, { - "in": "query", - "name": "page", + "in": "path", + "name": "user_pk", "schema": { "type": "integer" }, - "description": "Page number (24 rows per page)." + "required": true } ], "tags": [ "Admin machines" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedAssignOperator" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedAssignOperator" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedAssignOperator" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -19724,33 +19396,95 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CertificationTypePage" + "$ref": "#/components/schemas/MachineOperator" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "description": "Invalid operator assignment." + } + } + }, + "delete": { + "operationId": "api_v1_admin_machines_operators_destroy", + "summary": "Remove a machine operator", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + { + "in": "path", + "name": "user_pk", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + } + } + } + }, + "/api/v1/admin/machines/{id}/publicity": { + "get": { + "operationId": "api_v1_admin_machines_publicity_retrieve", + "summary": "Preview a machine public listing", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicMachine" + } + } + }, + "description": "" } } }, - "post": { - "operationId": "api_v1_admin_makerspace_certification_types_create", - "summary": "Create a certification type", + "patch": { + "operationId": "api_v1_admin_machines_publicity_partial_update", + "summary": "Set machine public visibility", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -19764,21 +19498,20 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CertificationType" + "$ref": "#/components/schemas/PatchedMachinePublicity" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/CertificationType" + "$ref": "#/components/schemas/PatchedMachinePublicity" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/CertificationType" + "$ref": "#/components/schemas/PatchedMachinePublicity" } } - }, - "required": true + } }, "security": [ { @@ -19786,68 +19519,44 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CertificationType" + "$ref": "#/components/schemas/PublicMachine" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." + "description": "Invalid publicity setting." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "description": "MANAGE_MACHINES is required." }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "423": { - "description": "Makerspace frozen for tenant migration." + "description": "Machine not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/containers": { - "get": { - "operationId": "api_v1_admin_makerspace_containers_list", - "summary": "List containers", + "/api/v1/admin/machines/{id}/retire": { + "post": { + "operationId": "api_v1_admin_machines_retire_create", + "summary": "Retire a machine", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } - }, - { - "name": "page_size", - "required": false, - "in": "query", - "description": "Number of results to return per page.", - "schema": { - "type": "integer" - } } ], "tags": [ - "Containers" + "Admin machines" ], "security": [ { @@ -19859,61 +19568,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedBoxList" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/Machine" } } }, "description": "" } } - }, + } + }, + "/api/v1/admin/machines/{id}/set-status": { "post": { - "operationId": "api_v1_admin_makerspace_containers_create", - "summary": "Create container", + "operationId": "api_v1_admin_machines_set_status_create", + "summary": "Set a machine status", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -19921,23 +19592,23 @@ } ], "tags": [ - "Containers" + "Admin machines" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Box" + "$ref": "#/components/schemas/SetStatus" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/Box" + "$ref": "#/components/schemas/SetStatus" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/Box" + "$ref": "#/components/schemas/SetStatus" } } }, @@ -19949,51 +19620,50 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Box" + "$ref": "#/components/schemas/Machine" } } }, "description": "" }, "400": { + "description": "Invalid machine status." + } + } + } + }, + "/api/v1/admin/machines/{id}/unretire": { + "post": { + "operationId": "api_v1_admin_machines_unretire_create", + "summary": "Reactivate a retired machine", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/Machine" } } }, @@ -20002,14 +19672,14 @@ } } }, - "/api/v1/admin/makerspace/{makerspace_id}/cover": { - "post": { - "operationId": "api_v1_admin_makerspace_cover_create", - "summary": "Create a makerspace public image upload URL", + "/api/v1/admin/machines/{id}/usage": { + "get": { + "operationId": "api_v1_admin_machines_usage_list", + "summary": "List machine usage entries", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -20017,65 +19687,36 @@ } ], "tags": [ - "Admin makerspaces" + "Admin machines" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicImageUploadResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineUsageEntry" + } } } }, "description": "" - }, - "400": { - "description": "Invalid image upload request." - }, - "403": { - "description": "The actor lacks manage_makerspace here." - }, - "404": { - "description": "Makerspace not found, archived, not active, or outside the actor's scope." - }, - "503": { - "description": "Public image storage is unavailable." } } }, - "put": { - "operationId": "api_v1_admin_makerspace_cover_update", - "summary": "Attach an uploaded public image to a makerspace", + "post": { + "operationId": "api_v1_admin_machines_usage_create", + "summary": "Log machine usage", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -20083,23 +19724,23 @@ } ], "tags": [ - "Admin makerspaces" + "Admin machines" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" + "$ref": "#/components/schemas/LogUsage" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" + "$ref": "#/components/schemas/LogUsage" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" + "$ref": "#/components/schemas/LogUsage" } } }, @@ -20111,45 +19752,52 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Makerspace" + "$ref": "#/components/schemas/MachineUsageEntry" } } }, "description": "" }, "400": { - "description": "Invalid image object key or size." - }, - "403": { - "description": "The actor lacks manage_makerspace here." - }, - "404": { - "description": "Makerspace not found, archived, not active, or outside the actor's scope." - }, - "503": { - "description": "Public image storage is unavailable." + "description": "Invalid usage entry." } } - }, - "delete": { - "operationId": "api_v1_admin_makerspace_cover_destroy", - "summary": "Clear a makerspace public image", + } + }, + "/api/v1/admin/machines/{id}/usage-candidates": { + "get": { + "operationId": "api_v1_admin_machines_usage_candidates_retrieve", + "summary": "Find active members for manual usage attribution", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + } } ], "tags": [ - "Admin makerspaces" + "Admin machines" ], "security": [ { @@ -20161,29 +19809,35 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Makerspace" + "$ref": "#/components/schemas/MachineUsageCandidatesPage" } } }, "description": "" }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, "403": { - "description": "The actor lacks manage_makerspace here." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "description": "Makerspace not found, archived, not active, or outside the actor's scope." + "description": "Not found, not visible to this actor, or the surface is not enabled here." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/dashboard": { + "/api/v1/admin/machines/{id}/warranty": { "get": { - "operationId": "api_v1_admin_makerspace_dashboard_retrieve", - "summary": "Staff operations dashboard counts", + "operationId": "api_v1_admin_machines_warranty_retrieve", + "summary": "Retrieve warranty details for a machine", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -20191,7 +19845,7 @@ } ], "tags": [ - "Dashboard" + "Admin warranty" ], "security": [ { @@ -20203,7 +19857,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Dashboard" + "$ref": "#/components/schemas/Warranty" } } }, @@ -20217,7 +19871,7 @@ } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { @@ -20227,7 +19881,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -20237,59 +19891,17 @@ } } }, - "description": "Makerspace not found." - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/data-exports": { - "get": { - "operationId": "api_v1_admin_makerspace_data_exports_list", - "summary": "List makerspace data-export jobs", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Data exports" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DataExportJob" - } - } - } - }, "description": "" - }, - "403": { - "description": "MANAGE_MAKERSPACE is required." } } }, - "post": { - "operationId": "api_v1_admin_makerspace_data_exports_create", - "summary": "Request a redacted makerspace data export", + "put": { + "operationId": "api_v1_admin_machines_warranty_update", + "summary": "Create or update warranty details for a machine", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -20297,23 +19909,23 @@ } ], "tags": [ - "Data exports" + "Admin warranty" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DataExportCreate" + "$ref": "#/components/schemas/WarrantyUpsert" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/DataExportCreate" + "$ref": "#/components/schemas/WarrantyUpsert" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/DataExportCreate" + "$ref": "#/components/schemas/WarrantyUpsert" } } } @@ -20324,45 +19936,89 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DataExportJob" + "$ref": "#/components/schemas/Warranty" } } }, "description": "" }, "400": { - "description": "Invalid fidelity or quota/module policy failure." + "description": "Invalid warranty details." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "MANAGE_MAKERSPACE is required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, - "429": { - "description": "Export creation rate limit exceeded." + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/data-exports/{job_id}": { - "get": { - "operationId": "api_v1_admin_makerspace_data_exports_retrieve", - "summary": "Poll a makerspace data-export job", + "/api/v1/admin/machines/documents/{id}": { + "delete": { + "operationId": "api_v1_admin_machines_documents_destroy", + "summary": "Delete a machine document", "parameters": [ { "in": "path", - "name": "job_id", + "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true - }, + } + ], + "tags": [ + "Admin machines" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + } + } + } + }, + "/api/v1/admin/machines/documents/{id}/url": { + "get": { + "operationId": "api_v1_admin_machines_documents_url_retrieve", + "summary": "Create a signed machine document view URL", + "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -20370,7 +20026,7 @@ } ], "tags": [ - "Data exports" + "Admin machines" ], "security": [ { @@ -20379,41 +20035,22 @@ ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DataExportJob" - } - } - }, - "description": "" - }, - "403": { - "description": "MANAGE_MAKERSPACE is required." + "description": "Signed machine document URL." }, - "404": { - "description": "Export job not found in this makerspace." + "503": { + "description": "Machine document storage is unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/data-exports/{job_id}/download-url": { - "post": { - "operationId": "api_v1_admin_makerspace_data_exports_download_url_create", - "summary": "Issue a short-lived one-use export download URL", + "/api/v1/admin/maintenance/log-documents/{id}/": { + "delete": { + "operationId": "api_v1_admin_maintenance_log_documents_destroy", + "summary": "Delete a maintenance document", "parameters": [ { "in": "path", - "name": "job_id", - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true - }, - { - "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -20421,7 +20058,7 @@ } ], "tags": [ - "Data exports" + "Admin maintenance" ], "security": [ { @@ -20429,52 +20066,78 @@ } ], "responses": { - "200": { + "204": { + "description": "No response body" + }, + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DataExportDownloadUrl" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" - }, - "400": { - "description": "Export is not available." + "description": "Invalid request." }, "403": { - "description": "MANAGE_MAKERSPACE is required; PORTABLE additionally requires source superadmin." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "Export job not found in this makerspace." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Service unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/device-printer-bindings": { + "/api/v1/admin/maintenance/log-documents/{id}/url/": { "get": { - "operationId": "api_v1_admin_makerspace_device_printer_bindings_list", + "operationId": "api_v1_admin_maintenance_log_documents_url_retrieve", + "summary": "Create a private maintenance document URL", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } } ], "tags": [ - "Devices" + "Admin maintenance" ], "security": [ { @@ -20486,7 +20149,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedBindingList" + "$ref": "#/components/schemas/MaintenanceDocumentUrl" } } }, @@ -20496,80 +20159,63 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Invalid payload or adapter module disabled." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication/signature/replay refused." + "description": "Invalid request." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authority or device revoked." + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Target outside scope or absent." + "description": "Not found." }, "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Conflicting command/report." - }, - "410": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Delivery lease expired." + "description": "Workflow conflict." }, - "423": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant frozen for migration." + "description": "Service unavailable." } } - }, + } + }, + "/api/v1/admin/maintenance/logs/{id}/documents/": { "post": { - "operationId": "api_v1_admin_makerspace_device_printer_bindings_create", + "operationId": "api_v1_admin_maintenance_logs_documents_create", + "summary": "Finalize a maintenance document upload", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -20577,23 +20223,23 @@ } ], "tags": [ - "Devices" + "Admin maintenance" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BindingCreate" + "$ref": "#/components/schemas/MaintenanceDocumentFinalize" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/BindingCreate" + "$ref": "#/components/schemas/MaintenanceDocumentFinalize" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/BindingCreate" + "$ref": "#/components/schemas/MaintenanceDocumentFinalize" } } }, @@ -20609,7 +20255,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Binding" + "$ref": "#/components/schemas/MaintenanceLogDocument" } } }, @@ -20619,101 +20265,92 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Invalid payload or adapter module disabled." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication/signature/replay refused." + "description": "Invalid request." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authority or device revoked." + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Target outside scope or absent." + "description": "Not found." }, "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Conflicting command/report." - }, - "410": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Delivery lease expired." + "description": "Workflow conflict." }, - "423": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant frozen for migration." + "description": "Service unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/devices": { - "get": { - "operationId": "api_v1_admin_makerspace_devices_list", - "summary": "List a makerspace's devices", + "/api/v1/admin/maintenance/logs/{id}/documents/presign/": { + "post": { + "operationId": "api_v1_admin_maintenance_logs_documents_presign_create", + "summary": "Create a maintenance document upload URL", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } } ], "tags": [ - "Devices" + "Admin maintenance" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceDocumentPresign" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MaintenanceDocumentPresign" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MaintenanceDocumentPresign" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -20724,7 +20361,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedDeviceList" + "$ref": "#/components/schemas/MaintenanceDocumentPresignResponse" } } }, @@ -20734,61 +20371,63 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request or module disabled." + "description": "Invalid request." }, - "401": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "Permission denied." }, - "403": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Permission denied." + "description": "Not found." }, - "404": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Not found." + "description": "Workflow conflict." }, - "423": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant frozen for migration." + "description": "Service unavailable." } } - }, - "post": { - "operationId": "api_v1_admin_makerspace_devices_create", - "summary": "Create a pending device and its first enrollment", + } + }, + "/api/v1/admin/maintenance/schedules/{id}/": { + "patch": { + "operationId": "api_v1_admin_maintenance_schedules_partial_update", + "summary": "Update a maintenance schedule", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -20796,27 +20435,26 @@ } ], "tags": [ - "Devices" + "Admin maintenance" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceCreate" + "$ref": "#/components/schemas/PatchedMaintenanceScheduleWrite" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/DeviceCreate" + "$ref": "#/components/schemas/PatchedMaintenanceScheduleWrite" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/DeviceCreate" + "$ref": "#/components/schemas/PatchedMaintenanceScheduleWrite" } } - }, - "required": true + } }, "security": [ { @@ -20824,93 +20462,53 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceCreated" + "$ref": "#/components/schemas/MaintenanceSchedule" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Invalid request or module disabled." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Authentication required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Permission denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Not found." + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "409": { + "description": "Conflicts with the current state." }, "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Tenant frozen for migration." + "description": "Makerspace writes are frozen for tenant migration." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/direct-loan-members": { - "get": { - "operationId": "api_v1_admin_makerspace_direct_loan_members_list", + "/api/v1/admin/maintenance/schedules/{id}/deactivate/": { + "post": { + "operationId": "api_v1_admin_maintenance_schedules_deactivate_create", + "summary": "Deactivate a maintenance schedule", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } } ], "tags": [ - "api" + "Admin maintenance" ], "security": [ { @@ -20922,20 +20520,49 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedDirectLoanMemberList" + "$ref": "#/components/schemas/MaintenanceSchedule" } } }, "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "409": { + "description": "Conflicts with the current state." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/direct-loans": { + "/api/v1/admin/makerspace/{makerspace_id}/accepted-requests": { "get": { - "operationId": "api_v1_admin_makerspace_direct_loans_list", - "summary": "List direct handout loans", + "operationId": "api_v1_admin_makerspace_accepted_requests_list", + "summary": "List accepted requests awaiting issue", "parameters": [ + { + "name": "id", + "in": "query", + "required": false, + "description": "Select one result within the authorized list.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + } + }, { "in": "path", "name": "makerspace_id", @@ -20944,6 +20571,15 @@ }, "required": true }, + { + "name": "ordering", + "required": false, + "in": "query", + "description": "Which field to use when ordering the results.", + "schema": { + "type": "string" + } + }, { "name": "page", "required": false, @@ -20952,6 +20588,15 @@ "schema": { "type": "integer" } + }, + { + "name": "search", + "required": false, + "in": "query", + "description": "A search term (requested-for, requester name/email).", + "schema": { + "type": "string" + } } ], "tags": [ @@ -20967,7 +20612,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedDirectLoanList" + "$ref": "#/components/schemas/PaginatedAdminRequestList" } } }, @@ -21012,22 +20657,14 @@ } }, "description": "Not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Workflow conflict." } } - }, - "post": { - "operationId": "api_v1_admin_makerspace_direct_loans_create", - "summary": "Issue direct handout without public request", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/accountability": { + "get": { + "operationId": "api_v1_admin_makerspace_accountability_retrieve", + "summary": "Requester accountability dashboard", "parameters": [ { "in": "path", @@ -21039,39 +20676,20 @@ } ], "tags": [ - "Admin requests" + "Analytics" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DirectLoanIssue" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/DirectLoanIssue" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/DirectLoanIssue" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DirectLoan" + "type": "object", + "additionalProperties": {} } } }, @@ -21081,17 +20699,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Invalid request." + "description": "Invalid report request." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, @@ -21101,7 +20719,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, @@ -21111,40 +20729,31 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Workflow conflict." - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Service unavailable." + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/email-logs": { + "/api/v1/admin/makerspace/{makerspace_id}/active-loans": { "get": { - "operationId": "api_v1_admin_makerspace_email_logs_list", - "summary": "List makerspace email delivery logs", + "operationId": "api_v1_admin_makerspace_active_loans_list", + "summary": "List active loans awaiting return", "parameters": [ + { + "name": "id", + "in": "query", + "required": false, + "description": "Select one result within the authorized list.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + } + }, { "in": "path", "name": "makerspace_id", @@ -21154,32 +20763,35 @@ "required": true }, { - "name": "page", + "name": "ordering", "required": false, "in": "query", - "description": "A page number within the paginated result set.", + "description": "Which field to use when ordering the results.", "schema": { - "type": "integer" + "type": "string" } }, { + "name": "page", + "required": false, "in": "query", - "name": "recent_days", + "description": "A page number within the paginated result set.", "schema": { "type": "integer" - }, - "description": "Limit to the last 1–365 days." + } }, { + "name": "search", + "required": false, "in": "query", - "name": "status", + "description": "A search term (requested-for, requester name/email).", "schema": { "type": "string" } } ], "tags": [ - "Email logs" + "Admin requests" ], "security": [ { @@ -21191,51 +20803,169 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedEmailLogList" + "$ref": "#/components/schemas/PaginatedAdminRequestList" } } }, "description": "" }, "400": { - "description": "Invalid status filter." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." }, "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, "description": "Authentication required." }, "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, "description": "Permission denied." }, "404": { - "description": "Makerspace not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/email-logs/{id}/retry": { - "post": { - "operationId": "api_v1_admin_makerspace_email_logs_retry_create", - "summary": "Retry a failed makerspace email", + "/api/v1/admin/makerspace/{makerspace_id}/analytics/{report_key}": { + "get": { + "operationId": "api_v1_admin_makerspace_analytics_retrieve", + "description": "Subject reports require live resource management plus tenant VIEW_AUDIT. For bookable_space use booking-resource-utilization, booking-reservations or booking-answers; bookings.analytics / bookings.exports must be enabled. Resource PDF requires tenant MANAGE_BOOKINGS, bookings.utilization_pdf and complete start/end dates. Dates are tenant-local, end inclusive; reservation detail includes overlapping windows. No credentials are exported. Event PDFs require full event management and an ended event. Subjects cannot be scheduled or aggregated. Active-loan and return detail rows include count=1 for chart summaries of returned rows. Event, booking and audit detail exports retain their explicit column allowlists and use table specs. FabLab health rows include makerspace_id in tenant previews.", + "summary": "Get analytics report", "parameters": [ + { + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month", + "week" + ] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, "required": true }, { - "in": "path", - "name": "makerspace_id", + "in": "query", + "name": "months", "schema": { "type": "integer" }, + "description": "Retention anniversary in months (1-120); default 3, 6 and 12." + }, + { + "in": "path", + "name": "report_key", + "schema": { + "type": "string" + }, "required": true + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_id", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "subject_kind", + "schema": { + "type": "string" + }, + "description": "Typed tenant-local subject (event or bookable_space), paired with subject_id. Booking resources require manage_reservations plus tenant VIEW_AUDIT; resource PDF requires tenant MANAGE_BOOKINGS and both dates. Subject filters cannot be scheduled or aggregated." + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" + ] + } } ], "tags": [ - "Email logs" + "Analytics" ], "security": [ { @@ -21247,196 +20977,162 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailLog" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" }, "400": { - "description": "Email log cannot be retried." - }, - "404": { - "description": "Email log not found." - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/email-templates": { - "get": { - "operationId": "api_v1_admin_makerspace_email_templates_list", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Email templates" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EmailTemplateListItem" - } + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Invalid report request." }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/email-templates/{stream}/{audience}/{key}": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/active-loans": { "get": { - "operationId": "api_v1_admin_makerspace_email_templates_retrieve", + "operationId": "api_v1_admin_makerspace_analytics_active_loans_retrieve", + "description": "Subject reports require live resource management plus tenant VIEW_AUDIT. For bookable_space use booking-resource-utilization, booking-reservations or booking-answers; bookings.analytics / bookings.exports must be enabled. Resource PDF requires tenant MANAGE_BOOKINGS, bookings.utilization_pdf and complete start/end dates. Dates are tenant-local, end inclusive; reservation detail includes overlapping windows. No credentials are exported. Event PDFs require full event management and an ended event. Subjects cannot be scheduled or aggregated. Active-loan and return detail rows include count=1 for chart summaries of returned rows. Event, booking and audit detail exports retain their explicit column allowlists and use table specs. FabLab health rows include makerspace_id in tenant previews.", + "summary": "Get analytics report", "parameters": [ { - "in": "path", - "name": "audience", + "in": "query", + "name": "end", "schema": { - "type": "string" - }, - "required": true + "type": "string", + "format": "date" + } }, { - "in": "path", - "name": "key", + "in": "query", + "name": "grain", "schema": { - "type": "string" - }, - "required": true + "type": "string", + "enum": [ + "day", + "month", + "week" + ] + } }, { - "in": "path", - "name": "makerspace_id", + "in": "query", + "name": "limit", "schema": { "type": "integer" - }, - "required": true + } }, { "in": "path", - "name": "stream", + "name": "makerspace_id", "schema": { - "type": "string" + "type": "integer" }, "required": true - } - ], - "tags": [ - "Email templates" - ], - "security": [ + }, { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EmailTemplateDetail" - } - } + "in": "query", + "name": "months", + "schema": { + "type": "integer" }, - "description": "" - }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "description": "Retention anniversary in months (1-120); default 3, 6 and 12." }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - } - } - }, - "patch": { - "operationId": "api_v1_admin_makerspace_email_templates_partial_update", - "parameters": [ { - "in": "path", - "name": "audience", + "in": "query", + "name": "start", "schema": { - "type": "string" - }, - "required": true + "type": "string", + "format": "date" + } }, { - "in": "path", - "name": "key", + "in": "query", + "name": "status", "schema": { - "type": "string" - }, - "required": true + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } }, { - "in": "path", - "name": "makerspace_id", + "in": "query", + "name": "subject_id", "schema": { "type": "integer" - }, - "required": true + } }, { - "in": "path", - "name": "stream", + "in": "query", + "name": "subject_kind", "schema": { "type": "string" }, - "required": true + "description": "Typed tenant-local subject (event or bookable_space), paired with subject_id. Booking resources require manage_reservations plus tenant VIEW_AUDIT; resource PDF requires tenant MANAGE_BOOKINGS and both dates. Subject filters cannot be scheduled or aggregated." + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" + ] + } } ], "tags": [ - "Email templates" + "Analytics" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -21447,46 +21143,87 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailTemplateDetail" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Invalid report request." }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/email-templates/{stream}/{audience}/{key}/reset": { - "post": { - "operationId": "api_v1_admin_makerspace_email_templates_reset_create", + "/api/v1/admin/makerspace/{makerspace_id}/analytics/booking-utilization": { + "get": { + "operationId": "api_v1_admin_makerspace_analytics_booking_utilization_retrieve", + "description": "Subject reports require live resource management plus tenant VIEW_AUDIT. For bookable_space use booking-resource-utilization, booking-reservations or booking-answers; bookings.analytics / bookings.exports must be enabled. Resource PDF requires tenant MANAGE_BOOKINGS, bookings.utilization_pdf and complete start/end dates. Dates are tenant-local, end inclusive; reservation detail includes overlapping windows. No credentials are exported. Event PDFs require full event management and an ended event. Subjects cannot be scheduled or aggregated. Active-loan and return detail rows include count=1 for chart summaries of returned rows. Event, booking and audit detail exports retain their explicit column allowlists and use table specs. FabLab health rows include makerspace_id in tenant previews.", + "summary": "Get analytics report", "parameters": [ { - "in": "path", - "name": "audience", + "in": "query", + "name": "end", "schema": { - "type": "string" - }, - "required": true + "type": "string", + "format": "date" + } }, { - "in": "path", - "name": "key", + "in": "query", + "name": "grain", "schema": { - "type": "string" - }, - "required": true + "type": "string", + "enum": [ + "day", + "month", + "week" + ] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } }, { "in": "path", @@ -21497,16 +21234,70 @@ "required": true }, { - "in": "path", - "name": "stream", + "in": "query", + "name": "months", + "schema": { + "type": "integer" + }, + "description": "Retention anniversary in months (1-120); default 3, 6 and 12." + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_id", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "subject_kind", "schema": { "type": "string" }, - "required": true + "description": "Typed tenant-local subject (event or bookable_space), paired with subject_id. Booking resources require manage_reservations plus tenant VIEW_AUDIT; resource PDF requires tenant MANAGE_BOOKINGS and both dates. Subject filters cannot be scheduled or aggregated." + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" + ] + } } ], "tags": [ - "Email templates" + "Analytics" ], "security": [ { @@ -21518,54 +21309,87 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailTemplateDetail" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Invalid report request." }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/email-templates/{stream}/{audience}/{key}/types/{machine_type_id}": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/damaged-lost": { "get": { - "operationId": "api_v1_admin_makerspace_email_templates_types_retrieve", + "operationId": "api_v1_admin_makerspace_analytics_damaged_lost_retrieve", + "description": "Subject reports require live resource management plus tenant VIEW_AUDIT. For bookable_space use booking-resource-utilization, booking-reservations or booking-answers; bookings.analytics / bookings.exports must be enabled. Resource PDF requires tenant MANAGE_BOOKINGS, bookings.utilization_pdf and complete start/end dates. Dates are tenant-local, end inclusive; reservation detail includes overlapping windows. No credentials are exported. Event PDFs require full event management and an ended event. Subjects cannot be scheduled or aggregated. Active-loan and return detail rows include count=1 for chart summaries of returned rows. Event, booking and audit detail exports retain their explicit column allowlists and use table specs. FabLab health rows include makerspace_id in tenant previews.", + "summary": "Get analytics report", "parameters": [ { - "in": "path", - "name": "audience", + "in": "query", + "name": "end", "schema": { - "type": "string" - }, - "required": true + "type": "string", + "format": "date" + } }, { - "in": "path", - "name": "key", + "in": "query", + "name": "grain", "schema": { - "type": "string" - }, - "required": true + "type": "string", + "enum": [ + "day", + "month", + "week" + ] + } }, { - "in": "path", - "name": "machine_type_id", + "in": "query", + "name": "limit", "schema": { "type": "integer" - }, - "required": true + } }, { "in": "path", @@ -21576,16 +21400,70 @@ "required": true }, { - "in": "path", - "name": "stream", + "in": "query", + "name": "months", + "schema": { + "type": "integer" + }, + "description": "Retention anniversary in months (1-120); default 3, 6 and 12." + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_id", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "subject_kind", "schema": { "type": "string" }, - "required": true + "description": "Typed tenant-local subject (event or bookable_space), paired with subject_id. Booking resources require manage_reservations plus tenant VIEW_AUDIT; resource PDF requires tenant MANAGE_BOOKINGS and both dates. Subject filters cannot be scheduled or aggregated." + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" + ] + } } ], "tags": [ - "Email templates" + "Analytics" ], "security": [ { @@ -21597,52 +21475,87 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailTemplateDetail" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Invalid report request." }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } - }, - "patch": { - "operationId": "api_v1_admin_makerspace_email_templates_types_partial_update", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/analytics/damaged-missing": { + "get": { + "operationId": "api_v1_admin_makerspace_analytics_damaged_missing_retrieve", + "description": "Subject reports require live resource management plus tenant VIEW_AUDIT. For bookable_space use booking-resource-utilization, booking-reservations or booking-answers; bookings.analytics / bookings.exports must be enabled. Resource PDF requires tenant MANAGE_BOOKINGS, bookings.utilization_pdf and complete start/end dates. Dates are tenant-local, end inclusive; reservation detail includes overlapping windows. No credentials are exported. Event PDFs require full event management and an ended event. Subjects cannot be scheduled or aggregated. Active-loan and return detail rows include count=1 for chart summaries of returned rows. Event, booking and audit detail exports retain their explicit column allowlists and use table specs. FabLab health rows include makerspace_id in tenant previews.", + "summary": "Get analytics report", "parameters": [ { - "in": "path", - "name": "audience", + "in": "query", + "name": "end", "schema": { - "type": "string" - }, - "required": true + "type": "string", + "format": "date" + } }, { - "in": "path", - "name": "key", + "in": "query", + "name": "grain", "schema": { - "type": "string" - }, - "required": true + "type": "string", + "enum": [ + "day", + "month", + "week" + ] + } }, { - "in": "path", - "name": "machine_type_id", + "in": "query", + "name": "limit", "schema": { "type": "integer" - }, - "required": true + } }, { "in": "path", @@ -21653,114 +21566,70 @@ "required": true }, { - "in": "path", - "name": "stream", + "in": "query", + "name": "months", "schema": { - "type": "string" - }, - "required": true - } - ], - "tags": [ - "Email templates" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" - } - } - } - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EmailTemplateDetail" - } - } + "type": "integer" }, - "description": "" - }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "description": "Retention anniversary in months (1-120); default 3, 6 and 12." }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/email-templates/{stream}/{audience}/{key}/types/{machine_type_id}/reset": { - "post": { - "operationId": "api_v1_admin_makerspace_email_templates_types_reset_create", - "parameters": [ { - "in": "path", - "name": "audience", + "in": "query", + "name": "start", "schema": { - "type": "string" - }, - "required": true + "type": "string", + "format": "date" + } }, { - "in": "path", - "name": "key", + "in": "query", + "name": "status", "schema": { - "type": "string" - }, - "required": true + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } }, { - "in": "path", - "name": "machine_type_id", + "in": "query", + "name": "subject_id", "schema": { "type": "integer" - }, - "required": true + } }, { - "in": "path", - "name": "makerspace_id", + "in": "query", + "name": "subject_kind", "schema": { - "type": "integer" + "type": "string" }, - "required": true + "description": "Typed tenant-local subject (event or bookable_space), paired with subject_id. Booking resources require manage_reservations plus tenant VIEW_AUDIT; resource PDF requires tenant MANAGE_BOOKINGS and both dates. Subject filters cannot be scheduled or aggregated." }, { - "in": "path", - "name": "stream", + "in": "query", + "name": "subject_type", "schema": { - "type": "string" - }, - "required": true + "type": "string", + "enum": [ + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" + ] + } } ], "tags": [ - "Email templates" + "Analytics" ], "security": [ { @@ -21772,170 +21641,87 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailTemplateDetail" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/email-templates/preview": { - "post": { - "operationId": "api_v1_admin_makerspace_email_templates_preview_create", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Email templates" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EmailTemplatePreviewRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/EmailTemplatePreviewRequest" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/EmailTemplatePreviewRequest" - } - } + "description": "Invalid report request." }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailTemplatePreviewResponse" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" - }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." + "description": "Authentication required." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/integration-health": { - "get": { - "operationId": "api_v1_admin_makerspace_integration_health_retrieve", - "summary": "Get makerspace integration health", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Integration health" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IntegrationHealth" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Not allowed to manage this makerspace." + "description": "Permission denied." }, "404": { - "description": "Makerspace not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/inventory": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/event-attendance": { "get": { - "operationId": "api_v1_admin_makerspace_inventory_list", - "summary": "List or create inventory products", + "operationId": "api_v1_admin_makerspace_analytics_event_attendance_retrieve", + "description": "Subject reports require live resource management plus tenant VIEW_AUDIT. For bookable_space use booking-resource-utilization, booking-reservations or booking-answers; bookings.analytics / bookings.exports must be enabled. Resource PDF requires tenant MANAGE_BOOKINGS, bookings.utilization_pdf and complete start/end dates. Dates are tenant-local, end inclusive; reservation detail includes overlapping windows. No credentials are exported. Event PDFs require full event management and an ended event. Subjects cannot be scheduled or aggregated. Active-loan and return detail rows include count=1 for chart summaries of returned rows. Event, booking and audit detail exports retain their explicit column allowlists and use table specs. FabLab health rows include makerspace_id in tenant previews.", + "summary": "Get analytics report", "parameters": [ { "in": "query", - "name": "archived", + "name": "end", "schema": { - "type": "string" - }, - "description": "`true` for archived, `false` for active; `all` or omitted includes both." + "type": "string", + "format": "date" + } }, { - "name": "id", "in": "query", - "required": false, - "description": "Select one result within the authorized list.", + "name": "grain", "schema": { - "type": "integer", - "minimum": 1, - "maximum": 9223372036854775807 + "type": "string", + "enum": [ + "day", + "month", + "week" + ] } }, { "in": "query", - "name": "low_stock", + "name": "limit", "schema": { - "type": "string" - }, - "description": "`true` to list items at or below 20% of total." + "type": "integer" + } }, { "in": "path", @@ -21947,52 +21733,69 @@ }, { "in": "query", - "name": "ordering", + "name": "months", "schema": { - "type": "string" + "type": "integer" }, - "description": "Allowlisted sort field; prefix with '-' for descending." + "description": "Retention anniversary in months (1-120); default 3, 6 and 12." }, { "in": "query", - "name": "out_of_stock", + "name": "start", "schema": { "type": "string", - "enum": [ - "false", - "true" - ] - }, - "description": "`true` for exactly zero available, matching the dashboard tile." + "format": "date" + } }, { "in": "query", - "name": "page", + "name": "status", "schema": { - "type": "integer" - }, - "description": "Page number, 24 rows per page." + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } }, { - "name": "page_size", - "required": false, "in": "query", - "description": "Number of results to return per page.", + "name": "subject_id", "schema": { "type": "integer" } }, { "in": "query", - "name": "q", + "name": "subject_kind", "schema": { "type": "string" }, - "description": "Full-text search over name, storage location, tracking mode and description (\"phrases\", -exclusions, typo-tolerant on the name), or a category name." + "description": "Typed tenant-local subject (event or bookable_space), paired with subject_id. Booking resources require manage_reservations plus tenant VIEW_AUDIT; resource PDF requires tenant MANAGE_BOOKINGS and both dates. Subject filters cannot be scheduled or aggregated." + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" + ] + } } ], "tags": [ - "Admin inventory" + "Analytics" ], "security": [ { @@ -22004,7 +21807,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedInventoryProductAdminList" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, @@ -22014,48 +21817,78 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Invalid report request." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Makerspace or report not found." } } - }, - "post": { - "operationId": "api_v1_admin_makerspace_inventory_create", - "summary": "List or create inventory products", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/analytics/fablab-health": { + "get": { + "operationId": "api_v1_admin_makerspace_analytics_fablab_health_retrieve", + "description": "Subject reports require live resource management plus tenant VIEW_AUDIT. For bookable_space use booking-resource-utilization, booking-reservations or booking-answers; bookings.analytics / bookings.exports must be enabled. Resource PDF requires tenant MANAGE_BOOKINGS, bookings.utilization_pdf and complete start/end dates. Dates are tenant-local, end inclusive; reservation detail includes overlapping windows. No credentials are exported. Event PDFs require full event management and an ended event. Subjects cannot be scheduled or aggregated. Active-loan and return detail rows include count=1 for chart summaries of returned rows. Event, booking and audit detail exports retain their explicit column allowlists and use table specs. FabLab health rows include makerspace_id in tenant previews.", + "summary": "Get analytics report", "parameters": [ + { + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month", + "week" + ] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, { "in": "path", "name": "makerspace_id", @@ -22063,42 +21896,84 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "months", + "schema": { + "type": "integer" + }, + "description": "Retention anniversary in months (1-120); default 3, 6 and 12." + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_id", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "subject_kind", + "schema": { + "type": "string" + }, + "description": "Typed tenant-local subject (event or bookable_space), paired with subject_id. Booking resources require manage_reservations plus tenant VIEW_AUDIT; resource PDF requires tenant MANAGE_BOOKINGS and both dates. Subject filters cannot be scheduled or aggregated." + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" + ] + } } ], "tags": [ - "Admin inventory" + "Analytics" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InventoryProductAdminCreate" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/InventoryProductAdminCreate" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/InventoryProductAdminCreate" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InventoryProductAdmin" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, @@ -22108,85 +21983,76 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Invalid report request." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/inventory/export": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/machine-usage": { "get": { - "operationId": "api_v1_admin_makerspace_inventory_export_retrieve", - "summary": "Export inventory products as CSV or XLSX", + "operationId": "api_v1_admin_makerspace_analytics_machine_usage_retrieve", + "description": "Subject reports require live resource management plus tenant VIEW_AUDIT. For bookable_space use booking-resource-utilization, booking-reservations or booking-answers; bookings.analytics / bookings.exports must be enabled. Resource PDF requires tenant MANAGE_BOOKINGS, bookings.utilization_pdf and complete start/end dates. Dates are tenant-local, end inclusive; reservation detail includes overlapping windows. No credentials are exported. Event PDFs require full event management and an ended event. Subjects cannot be scheduled or aggregated. Active-loan and return detail rows include count=1 for chart summaries of returned rows. Event, booking and audit detail exports retain their explicit column allowlists and use table specs. FabLab health rows include makerspace_id in tenant previews.", + "summary": "Get analytics report", "parameters": [ { "in": "query", - "name": "archived", + "name": "end", "schema": { "type": "string", - "enum": [ - "all", - "false", - "true" - ] + "format": "date" } }, { "in": "query", - "name": "format", + "name": "grain", "schema": { "type": "string", "enum": [ - "csv", - "xlsx" + "day", + "month", + "week" ] } }, { "in": "query", - "name": "ids", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "low_stock", + "name": "limit", "schema": { - "type": "boolean" + "type": "integer" } }, { @@ -22199,335 +22065,70 @@ }, { "in": "query", - "name": "out_of_stock", + "name": "months", "schema": { - "type": "string", - "enum": [ - "false", - "true" - ] + "type": "integer" }, - "description": "Exactly zero available, matching the dashboard list." + "description": "Retention anniversary in months (1-120); default 3, 6 and 12." }, { "in": "query", - "name": "q", + "name": "start", "schema": { - "type": "string" + "type": "string", + "format": "date" } - } - ], - "tags": [ - "Admin inventory" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "text/csv": { - "schema": { - "type": "string" - } - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { - "schema": { - "type": "string", - "format": "binary" - } - } - }, - "description": "" - }, - "400": { - "description": "Invalid request." }, - "401": { - "description": "Authentication credentials were not provided." - }, - "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/inventory/import/apply": { - "post": { - "operationId": "api_v1_admin_makerspace_inventory_import_apply_create", - "summary": "Apply inventory bulk import", - "parameters": [ { - "in": "path", - "name": "makerspace_id", + "in": "query", + "name": "status", "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Bulk import" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkImportPreview" - }, - "examples": { - "PreviewInventoryRows": { - "value": { - "rows": [ - { - "name": "Soldering Iron", - "total_quantity": 10, - "available_quantity": 8, - "is_public": true - } - ], - "mapping": { - "name": "name", - "total_quantity": "total_quantity" - } - }, - "summary": "Preview inventory rows" - } - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/BulkImportPreview" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/BulkImportPreview" - } + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] } - } - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "description": "Import application result." - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/inventory/import/jobs": { - "post": { - "operationId": "api_v1_admin_makerspace_inventory_import_jobs_create", - "summary": "Create an async inventory bulk import job", - "parameters": [ + }, { - "in": "path", - "name": "makerspace_id", + "in": "query", + "name": "subject_id", "schema": { "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Bulk import" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkImportJobCreate" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/BulkImportJobCreate" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/BulkImportJobCreate" - } } }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkImportJob" - } - } - }, - "description": "" - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/inventory/import/jobs/{job_id}": { - "get": { - "operationId": "api_v1_admin_makerspace_inventory_import_jobs_retrieve", - "summary": "Get async inventory bulk import job status", - "parameters": [ { - "in": "path", - "name": "job_id", + "in": "query", + "name": "subject_kind", "schema": { - "type": "integer" + "type": "string" }, - "required": true + "description": "Typed tenant-local subject (event or bookable_space), paired with subject_id. Booking resources require manage_reservations plus tenant VIEW_AUDIT; resource PDF requires tenant MANAGE_BOOKINGS and both dates. Subject filters cannot be scheduled or aggregated." }, { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Bulk import" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkImportJob" - } - } - }, - "description": "" - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/inventory/import/preview": { - "post": { - "operationId": "api_v1_admin_makerspace_inventory_import_preview_create", - "summary": "Preview inventory bulk import", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", + "in": "query", + "name": "subject_type", "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Bulk import" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkImportPreview" - }, - "examples": { - "PreviewInventoryRows": { - "value": { - "rows": [ - { - "name": "Soldering Iron", - "total_quantity": 10, - "available_quantity": 8, - "is_public": true - } - ], - "mapping": { - "name": "name", - "total_quantity": "total_quantity" - } - }, - "summary": "Preview inventory rows" - } - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/BulkImportPreview" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/BulkImportPreview" - } + "type": "string", + "enum": [ + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" + ] } } - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "description": "Import preview with row errors." - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/inventory/reorder-draft": { - "post": { - "operationId": "api_v1_admin_makerspace_inventory_reorder_draft_create", - "summary": "Create or reuse a procurement reorder draft", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } ], "tags": [ - "Admin inventory" + "Analytics" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReorderDraft" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ReorderDraft" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ReorderDraft" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -22538,17 +22139,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToBuyItem" - } - } - }, - "description": "" - }, - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ToBuyItem" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, @@ -22558,50 +22149,78 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Invalid report request." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/inventory/reorder-suggestions": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/maintenance-activity": { "get": { - "operationId": "api_v1_admin_makerspace_inventory_reorder_suggestions_list", - "summary": "List products at their reorder points", + "operationId": "api_v1_admin_makerspace_analytics_maintenance_activity_retrieve", + "description": "Subject reports require live resource management plus tenant VIEW_AUDIT. For bookable_space use booking-resource-utilization, booking-reservations or booking-answers; bookings.analytics / bookings.exports must be enabled. Resource PDF requires tenant MANAGE_BOOKINGS, bookings.utilization_pdf and complete start/end dates. Dates are tenant-local, end inclusive; reservation detail includes overlapping windows. No credentials are exported. Event PDFs require full event management and an ended event. Subjects cannot be scheduled or aggregated. Active-loan and return detail rows include count=1 for chart summaries of returned rows. Event, booking and audit detail exports retain their explicit column allowlists and use table specs. FabLab health rows include makerspace_id in tenant previews.", + "summary": "Get analytics report", "parameters": [ + { + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month", + "week" + ] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, { "in": "path", "name": "makerspace_id", @@ -22611,17 +22230,70 @@ "required": true }, { - "name": "page", - "required": false, "in": "query", - "description": "A page number within the paginated result set.", + "name": "months", + "schema": { + "type": "integer" + }, + "description": "Retention anniversary in months (1-120); default 3, 6 and 12." + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_id", "schema": { "type": "integer" } + }, + { + "in": "query", + "name": "subject_kind", + "schema": { + "type": "string" + }, + "description": "Typed tenant-local subject (event or bookable_space), paired with subject_id. Booking resources require manage_reservations plus tenant VIEW_AUDIT; resource PDF requires tenant MANAGE_BOOKINGS and both dates. Subject filters cannot be scheduled or aggregated." + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" + ] + } } ], "tags": [ - "Admin inventory" + "Analytics" ], "security": [ { @@ -22633,7 +22305,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedReorderSuggestionList" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, @@ -22643,93 +22315,151 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Invalid report request." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/kiosk-replays/": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/member-activity": { "get": { - "operationId": "api_v1_admin_makerspace_kiosk_replays_list", - "description": "Historical review remains available with inventory.kiosk disabled. No channel credentials or command payload are returned.", - "summary": "List failed handovers visible under current per-operation actions", + "operationId": "api_v1_admin_makerspace_analytics_member_activity_retrieve", + "description": "Subject reports require live resource management plus tenant VIEW_AUDIT. For bookable_space use booking-resource-utilization, booking-reservations or booking-answers; bookings.analytics / bookings.exports must be enabled. Resource PDF requires tenant MANAGE_BOOKINGS, bookings.utilization_pdf and complete start/end dates. Dates are tenant-local, end inclusive; reservation detail includes overlapping windows. No credentials are exported. Event PDFs require full event management and an ended event. Subjects cannot be scheduled or aggregated. Active-loan and return detail rows include count=1 for chart summaries of returned rows. Event, booking and audit detail exports retain their explicit column allowlists and use table specs. FabLab health rows include makerspace_id in tenant previews.", + "summary": "Get analytics report", "parameters": [ { - "in": "path", - "name": "makerspace_id", + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month", + "week" + ] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "makerspace_id", "schema": { "type": "integer" }, "required": true }, { - "name": "page", - "required": false, "in": "query", - "description": "A page number within the paginated result set.", + "name": "months", "schema": { "type": "integer" + }, + "description": "Retention anniversary in months (1-120); default 3, 6 and 12." + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" } }, { "in": "query", - "name": "resolution", + "name": "status", "schema": { "type": "string", "enum": [ - "all", - "resolved", - "unresolved" + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" ] } }, { "in": "query", - "name": "state", + "name": "subject_id", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "subject_kind", + "schema": { + "type": "string" + }, + "description": "Typed tenant-local subject (event or bookable_space), paired with subject_id. Booking resources require manage_reservations plus tenant VIEW_AUDIT; resource PDF requires tenant MANAGE_BOOKINGS and both dates. Subject filters cannot be scheduled or aggregated." + }, + { + "in": "query", + "name": "subject_type", "schema": { "type": "string", "enum": [ - "failed" + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" ] } } ], "tags": [ - "Admin kiosk recovery" + "Analytics" ], "security": [ { @@ -22741,7 +22471,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedReplayRecoveryList" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, @@ -22751,17 +22481,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Invalid filter/resolution or disabled required module." + "description": "Invalid report request." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, @@ -22771,50 +22501,58 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Account, current action or origin refused." + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Failure is unavailable in your scope." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Resolution/replacement conflict." - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Tenant source gate closed." + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/kiosk-stations/": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/most-lent": { "get": { - "operationId": "api_v1_admin_makerspace_kiosk_stations_list", - "summary": "List kiosk Stations", + "operationId": "api_v1_admin_makerspace_analytics_most_lent_retrieve", + "description": "Subject reports require live resource management plus tenant VIEW_AUDIT. For bookable_space use booking-resource-utilization, booking-reservations or booking-answers; bookings.analytics / bookings.exports must be enabled. Resource PDF requires tenant MANAGE_BOOKINGS, bookings.utilization_pdf and complete start/end dates. Dates are tenant-local, end inclusive; reservation detail includes overlapping windows. No credentials are exported. Event PDFs require full event management and an ended event. Subjects cannot be scheduled or aggregated. Active-loan and return detail rows include count=1 for chart summaries of returned rows. Event, booking and audit detail exports retain their explicit column allowlists and use table specs. FabLab health rows include makerspace_id in tenant previews.", + "summary": "Get analytics report", "parameters": [ + { + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month", + "week" + ] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, { "in": "path", "name": "makerspace_id", @@ -22822,139 +22560,84 @@ "type": "integer" }, "required": true - } - ], - "tags": [ - "Admin kiosk stations" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KioskStation" - } - } - } - }, - "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } + { + "in": "query", + "name": "months", + "schema": { + "type": "integer" }, - "description": "Feature disabled or invalid request." + "description": "Retention anniversary in months (1-120); default 3, 6 and 12." }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Makerspace access or step-up denied." + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Station not found." + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Rotate the PIN instead of revealing." + { + "in": "query", + "name": "subject_id", + "schema": { + "type": "integer" + } }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } + { + "in": "query", + "name": "subject_kind", + "schema": { + "type": "string" }, - "description": "Request rate limit exceeded." + "description": "Typed tenant-local subject (event or bookable_space), paired with subject_id. Booking resources require manage_reservations plus tenant VIEW_AUDIT; resource PDF requires tenant MANAGE_BOOKINGS and both dates. Subject filters cannot be scheduled or aggregated." }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Credential secrets are unavailable." - } - } - }, - "post": { - "operationId": "api_v1_admin_makerspace_kiosk_stations_create", - "summary": "Create a kiosk Station and its first PIN", - "parameters": [ { - "in": "path", - "name": "makerspace_id", + "in": "query", + "name": "subject_type", "schema": { - "type": "integer" - }, - "required": true + "type": "string", + "enum": [ + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" + ] + } } ], "tags": [ - "Admin kiosk stations" + "Analytics" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KioskStationCreate" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/KioskStationCreate" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/KioskStationCreate" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KioskStationSecret" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, @@ -22964,70 +22647,78 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Feature disabled or invalid request." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Makerspace access or step-up denied." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Station not found." + "description": "Invalid report request." }, - "409": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Rotate the PIN instead of revealing." + "description": "Authentication required." }, - "429": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Request rate limit exceeded." + "description": "Permission denied." }, - "503": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Credential secrets are unavailable." + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/kit-checklist-preview": { - "post": { - "operationId": "api_v1_admin_makerspace_kit_checklist_preview_create", - "summary": "Preview required issue or return kit components", + "/api/v1/admin/makerspace/{makerspace_id}/analytics/payment-reconciliation": { + "get": { + "operationId": "api_v1_admin_makerspace_analytics_payment_reconciliation_retrieve", + "description": "Subject reports require live resource management plus tenant VIEW_AUDIT. For bookable_space use booking-resource-utilization, booking-reservations or booking-answers; bookings.analytics / bookings.exports must be enabled. Resource PDF requires tenant MANAGE_BOOKINGS, bookings.utilization_pdf and complete start/end dates. Dates are tenant-local, end inclusive; reservation detail includes overlapping windows. No credentials are exported. Event PDFs require full event management and an ended event. Subjects cannot be scheduled or aggregated. Active-loan and return detail rows include count=1 for chart summaries of returned rows. Event, booking and audit detail exports retain their explicit column allowlists and use table specs. FabLab health rows include makerspace_id in tenant previews.", + "summary": "Get analytics report", "parameters": [ + { + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month", + "week" + ] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, { "in": "path", "name": "makerspace_id", @@ -23035,30 +22726,73 @@ "type": "integer" }, "required": true - } - ], - "tags": [ - "Admin requests" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KitPreviewInput" - } + }, + { + "in": "query", + "name": "months", + "schema": { + "type": "integer" }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/KitPreviewInput" - } + "description": "Retention anniversary in months (1-120); default 3, 6 and 12." + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_id", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "subject_kind", + "schema": { + "type": "string" }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/KitPreviewInput" - } + "description": "Typed tenant-local subject (event or bookable_space), paired with subject_id. Booking resources require manage_reservations plus tenant VIEW_AUDIT; resource PDF requires tenant MANAGE_BOOKINGS and both dates. Subject filters cannot be scheduled or aggregated." + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" + ] } } - }, + ], + "tags": [ + "Analytics" + ], "security": [ { "jwtAuth": [] @@ -23069,7 +22803,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KitPreview" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, @@ -23079,134 +22813,151 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Invalid report request." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/ledger": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/qr-scans": { "get": { - "operationId": "api_v1_admin_makerspace_ledger_retrieve", - "summary": "List outstanding inventory loans", + "operationId": "api_v1_admin_makerspace_analytics_qr_scans_retrieve", + "description": "Subject reports require live resource management plus tenant VIEW_AUDIT. For bookable_space use booking-resource-utilization, booking-reservations or booking-answers; bookings.analytics / bookings.exports must be enabled. Resource PDF requires tenant MANAGE_BOOKINGS, bookings.utilization_pdf and complete start/end dates. Dates are tenant-local, end inclusive; reservation detail includes overlapping windows. No credentials are exported. Event PDFs require full event management and an ended event. Subjects cannot be scheduled or aggregated. Active-loan and return detail rows include count=1 for chart summaries of returned rows. Event, booking and audit detail exports retain their explicit column allowlists and use table specs. FabLab health rows include makerspace_id in tenant previews.", + "summary": "Get analytics report", "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - }, { "in": "query", - "name": "overdue", + "name": "end", "schema": { - "type": "boolean" + "type": "string", + "format": "date" } }, { "in": "query", - "name": "page", + "name": "grain", "schema": { - "type": "integer" + "type": "string", + "enum": [ + "day", + "month", + "week" + ] } }, { "in": "query", - "name": "page_size", + "name": "limit", "schema": { "type": "integer" } }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "query", - "name": "search", + "name": "months", "schema": { - "type": "string" + "type": "integer" + }, + "description": "Retention anniversary in months (1-120); default 3, 6 and 12." + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" } }, { "in": "query", - "name": "sort", + "name": "status", "schema": { "type": "string", "enum": [ - "-due", - "-holder", - "-item_name", - "-makerspace_id", - "-quantity", - "-since", - "-source", - "due", - "holder", - "item_name", - "makerspace_id", - "quantity", - "since", - "source" + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" ] } }, { "in": "query", - "name": "source", + "name": "subject_id", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "subject_kind", + "schema": { + "type": "string" + }, + "description": "Typed tenant-local subject (event or bookable_space), paired with subject_id. Booking resources require manage_reservations plus tenant VIEW_AUDIT; resource PDF requires tenant MANAGE_BOOKINGS and both dates. Subject filters cannot be scheduled or aggregated." + }, + { + "in": "query", + "name": "subject_type", "schema": { "type": "string", "enum": [ - "direct", - "reviewed", - "self_checkout" + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" ] } } ], "tags": [ - "Ledger" + "Analytics" ], "security": [ { @@ -23218,31 +22969,88 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LedgerResponse" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Invalid report request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/ledger/export": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/recently-added": { "get": { - "operationId": "api_v1_admin_makerspace_ledger_export_retrieve", - "summary": "Export outstanding inventory loans", + "operationId": "api_v1_admin_makerspace_analytics_recently_added_retrieve", + "description": "Subject reports require live resource management plus tenant VIEW_AUDIT. For bookable_space use booking-resource-utilization, booking-reservations or booking-answers; bookings.analytics / bookings.exports must be enabled. Resource PDF requires tenant MANAGE_BOOKINGS, bookings.utilization_pdf and complete start/end dates. Dates are tenant-local, end inclusive; reservation detail includes overlapping windows. No credentials are exported. Event PDFs require full event management and an ended event. Subjects cannot be scheduled or aggregated. Active-loan and return detail rows include count=1 for chart summaries of returned rows. Event, booking and audit detail exports retain their explicit column allowlists and use table specs. FabLab health rows include makerspace_id in tenant previews.", + "summary": "Get analytics report", "parameters": [ { "in": "query", - "name": "format", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "grain", "schema": { "type": "string", "enum": [ - "csv", - "xlsx" + "day", + "month", + "week" ] } }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, { "in": "path", "name": "makerspace_id", @@ -23253,121 +23061,70 @@ }, { "in": "query", - "name": "overdue", + "name": "months", "schema": { - "type": "boolean" - } + "type": "integer" + }, + "description": "Retention anniversary in months (1-120); default 3, 6 and 12." }, { "in": "query", - "name": "search", + "name": "start", "schema": { - "type": "string" + "type": "string", + "format": "date" } }, { "in": "query", - "name": "sort", + "name": "status", "schema": { "type": "string", "enum": [ - "-due", - "-holder", - "-item_name", - "-makerspace_id", - "-quantity", - "-since", - "-source", - "due", - "holder", - "item_name", - "makerspace_id", - "quantity", - "since", - "source" + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" ] } }, { "in": "query", - "name": "source", + "name": "subject_id", "schema": { - "type": "string", - "enum": [ - "direct", - "reviewed", - "self_checkout" - ] + "type": "integer" } - } - ], - "tags": [ - "Ledger" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "text/csv": { - "schema": { - "type": "string" - } - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { - "schema": { - "type": "string", - "format": "binary" - } - } - }, - "description": "" - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/loans/{id}/renew": { - "post": { - "operationId": "api_v1_admin_makerspace_loans_renew_create", - "summary": "Extend an issued loan within its policy", - "parameters": [ + }, { - "in": "path", - "name": "id", + "in": "query", + "name": "subject_kind", "schema": { - "type": "integer" + "type": "string" }, - "required": true + "description": "Typed tenant-local subject (event or bookable_space), paired with subject_id. Booking resources require manage_reservations plus tenant VIEW_AUDIT; resource PDF requires tenant MANAGE_BOOKINGS and both dates. Subject filters cannot be scheduled or aggregated." }, { - "in": "path", - "name": "makerspace_id", + "in": "query", + "name": "subject_type", "schema": { - "type": "integer" - }, - "required": true + "type": "string", + "enum": [ + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" + ] + } } ], "tags": [ - "Loans" + "Analytics" ], - "requestBody": { - "content": { - "type": { - "schema": { - "type": "object", - "additionalProperties": {}, - "description": "Unspecified request body" - } - }, - "properties": { - "schema": {} - } - } - }, "security": [ { "jwtAuth": [] @@ -23378,7 +23135,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LoanRenewal" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, @@ -23388,182 +23145,78 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Invalid report request." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/logo": { - "post": { - "operationId": "api_v1_admin_makerspace_logo_create", - "summary": "Create a makerspace public image upload URL", + "/api/v1/admin/makerspace/{makerspace_id}/analytics/returns": { + "get": { + "operationId": "api_v1_admin_makerspace_analytics_returns_retrieve", + "description": "Subject reports require live resource management plus tenant VIEW_AUDIT. For bookable_space use booking-resource-utilization, booking-reservations or booking-answers; bookings.analytics / bookings.exports must be enabled. Resource PDF requires tenant MANAGE_BOOKINGS, bookings.utilization_pdf and complete start/end dates. Dates are tenant-local, end inclusive; reservation detail includes overlapping windows. No credentials are exported. Event PDFs require full event management and an ended event. Subjects cannot be scheduled or aggregated. Active-loan and return detail rows include count=1 for chart summaries of returned rows. Event, booking and audit detail exports retain their explicit column allowlists and use table specs. FabLab health rows include makerspace_id in tenant previews.", + "summary": "Get analytics report", "parameters": [ { - "in": "path", - "name": "makerspace_id", + "in": "query", + "name": "end", "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin makerspaces" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" - } + "type": "string", + "format": "date" } }, - "required": true - }, - "security": [ { - "jwtAuth": [] - } - ], - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadResponse" - } - } - }, - "description": "" - }, - "400": { - "description": "Invalid image upload request." - }, - "403": { - "description": "The actor lacks manage_makerspace here." - }, - "404": { - "description": "Makerspace not found, archived, not active, or outside the actor's scope." + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month", + "week" + ] + } }, - "503": { - "description": "Public image storage is unavailable." - } - } - }, - "put": { - "operationId": "api_v1_admin_makerspace_logo_update", - "summary": "Attach an uploaded public image to a makerspace", - "parameters": [ { - "in": "path", - "name": "makerspace_id", + "in": "query", + "name": "limit", "schema": { "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin makerspaces" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" - } } }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Makerspace" - } - } - }, - "description": "" - }, - "400": { - "description": "Invalid image object key or size." - }, - "403": { - "description": "The actor lacks manage_makerspace here." - }, - "404": { - "description": "Makerspace not found, archived, not active, or outside the actor's scope." - }, - "503": { - "description": "Public image storage is unavailable." - } - } - }, - "delete": { - "operationId": "api_v1_admin_makerspace_logo_destroy", - "summary": "Clear a makerspace public image", - "parameters": [ { "in": "path", "name": "makerspace_id", @@ -23571,44 +23224,18 @@ "type": "integer" }, "required": true - } - ], - "tags": [ - "Admin makerspaces" - ], - "security": [ + }, { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Makerspace" - } - } + "in": "query", + "name": "months", + "schema": { + "type": "integer" }, - "description": "" - }, - "403": { - "description": "The actor lacks manage_makerspace here." + "description": "Retention anniversary in months (1-120); default 3, 6 and 12." }, - "404": { - "description": "Makerspace not found, archived, not active, or outside the actor's scope." - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/machine-service-report": { - "get": { - "operationId": "api_v1_admin_makerspace_machine_service_report_retrieve", - "summary": "Retrieve makerspace machine-service report", - "parameters": [ { "in": "query", - "name": "end", + "name": "start", "schema": { "type": "string", "format": "date" @@ -23616,30 +23243,53 @@ }, { "in": "query", - "name": "machine_type", + "name": "status", "schema": { - "type": "string" + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] } }, { - "in": "path", - "name": "makerspace_id", + "in": "query", + "name": "subject_id", "schema": { "type": "integer" + } + }, + { + "in": "query", + "name": "subject_kind", + "schema": { + "type": "string" }, - "required": true + "description": "Typed tenant-local subject (event or bookable_space), paired with subject_id. Booking resources require manage_reservations plus tenant VIEW_AUDIT; resource PDF requires tenant MANAGE_BOOKINGS and both dates. Subject filters cannot be scheduled or aggregated." }, { "in": "query", - "name": "start", + "name": "subject_type", "schema": { "type": "string", - "format": "date" + "enum": [ + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" + ] } } ], "tags": [ - "Admin machine service" + "Analytics" ], "security": [ { @@ -23651,7 +23301,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceReportResponse" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, @@ -23661,29 +23311,78 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Invalid request." + "description": "Invalid report request." }, "401": { - "description": "Authentication credentials were not provided." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "Machine management permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "Not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/machine-type-pricing": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/summary": { "get": { - "operationId": "api_v1_admin_makerspace_machine_type_pricing_retrieve", - "summary": "List makerspace machine-type pricing", + "operationId": "api_v1_admin_makerspace_analytics_summary_retrieve", + "description": "Subject reports require live resource management plus tenant VIEW_AUDIT. For bookable_space use booking-resource-utilization, booking-reservations or booking-answers; bookings.analytics / bookings.exports must be enabled. Resource PDF requires tenant MANAGE_BOOKINGS, bookings.utilization_pdf and complete start/end dates. Dates are tenant-local, end inclusive; reservation detail includes overlapping windows. No credentials are exported. Event PDFs require full event management and an ended event. Subjects cannot be scheduled or aggregated. Active-loan and return detail rows include count=1 for chart summaries of returned rows. Event, booking and audit detail exports retain their explicit column allowlists and use table specs. FabLab health rows include makerspace_id in tenant previews.", + "summary": "Get analytics report", "parameters": [ + { + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month", + "week" + ] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, { "in": "path", "name": "makerspace_id", @@ -23691,10 +23390,72 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "months", + "schema": { + "type": "integer" + }, + "description": "Retention anniversary in months (1-120); default 3, 6 and 12." + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_id", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "subject_kind", + "schema": { + "type": "string" + }, + "description": "Typed tenant-local subject (event or bookable_space), paired with subject_id. Booking resources require manage_reservations plus tenant VIEW_AUDIT; resource PDF requires tenant MANAGE_BOOKINGS and both dates. Subject filters cannot be scheduled or aggregated." + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" + ] + } } ], "tags": [ - "Admin machines" + "Analytics" ], "security": [ { @@ -23706,30 +23467,87 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineTypePricingList" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Invalid report request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Authentication required." + }, "403": { - "description": "Space-manager identity required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/machine-type-pricing/{machine_type_id}": { - "put": { - "operationId": "api_v1_admin_makerspace_machine_type_pricing_update", - "summary": "Set makerspace machine-type pricing", + "/api/v1/admin/makerspace/{makerspace_id}/analytics/taken-items": { + "get": { + "operationId": "api_v1_admin_makerspace_analytics_taken_items_retrieve", + "description": "Subject reports require live resource management plus tenant VIEW_AUDIT. For bookable_space use booking-resource-utilization, booking-reservations or booking-answers; bookings.analytics / bookings.exports must be enabled. Resource PDF requires tenant MANAGE_BOOKINGS, bookings.utilization_pdf and complete start/end dates. Dates are tenant-local, end inclusive; reservation detail includes overlapping windows. No credentials are exported. Event PDFs require full event management and an ended event. Subjects cannot be scheduled or aggregated. Active-loan and return detail rows include count=1 for chart summaries of returned rows. Event, booking and audit detail exports retain their explicit column allowlists and use table specs. FabLab health rows include makerspace_id in tenant previews.", + "summary": "Get analytics report", "parameters": [ { - "in": "path", - "name": "machine_type_id", + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month", + "week" + ] + } + }, + { + "in": "query", + "name": "limit", "schema": { "type": "integer" - }, - "required": true + } }, { "in": "path", @@ -23738,31 +23556,73 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "months", + "schema": { + "type": "integer" + }, + "description": "Retention anniversary in months (1-120); default 3, 6 and 12." + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_id", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "subject_kind", + "schema": { + "type": "string" + }, + "description": "Typed tenant-local subject (event or bookable_space), paired with subject_id. Booking resources require manage_reservations plus tenant VIEW_AUDIT; resource PDF requires tenant MANAGE_BOOKINGS and both dates. Subject filters cannot be scheduled or aggregated." + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" + ] + } } ], "tags": [ - "Admin machines" + "Analytics" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MachineTypePricingSet" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/MachineTypePricingSet" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/MachineTypePricingSet" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -23773,29 +23633,88 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineTypePricing" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" }, "400": { - "description": "Invalid price." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Invalid report request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "Space-manager identity required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "Machine type not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/machine-types": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/top-borrowers": { "get": { - "operationId": "api_v1_admin_makerspace_machine_types_list", - "summary": "List machine types for a makerspace", + "operationId": "api_v1_admin_makerspace_analytics_top_borrowers_retrieve", + "description": "Subject reports require live resource management plus tenant VIEW_AUDIT. For bookable_space use booking-resource-utilization, booking-reservations or booking-answers; bookings.analytics / bookings.exports must be enabled. Resource PDF requires tenant MANAGE_BOOKINGS, bookings.utilization_pdf and complete start/end dates. Dates are tenant-local, end inclusive; reservation detail includes overlapping windows. No credentials are exported. Event PDFs require full event management and an ended event. Subjects cannot be scheduled or aggregated. Active-loan and return detail rows include count=1 for chart summaries of returned rows. Event, booking and audit detail exports retain their explicit column allowlists and use table specs. FabLab health rows include makerspace_id in tenant previews.", + "summary": "Get analytics report", "parameters": [ + { + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month", + "week" + ] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, { "in": "path", "name": "makerspace_id", @@ -23803,10 +23722,72 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "months", + "schema": { + "type": "integer" + }, + "description": "Retention anniversary in months (1-120); default 3, 6 and 12." + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_id", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "subject_kind", + "schema": { + "type": "string" + }, + "description": "Typed tenant-local subject (event or bookable_space), paired with subject_id. Booking resources require manage_reservations plus tenant VIEW_AUDIT; resource PDF requires tenant MANAGE_BOOKINGS and both dates. Subject filters cannot be scheduled or aggregated." + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" + ] + } } ], "tags": [ - "Admin machines" + "Analytics" ], "security": [ { @@ -23818,88 +23799,60 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineTypeAccess" - } + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" - } - } - }, - "post": { - "operationId": "api_v1_admin_makerspace_machine_types_create", - "summary": "Create a custom machine type", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MachineTypeCreate" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/MachineTypeCreate" + "description": "Invalid report request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/MachineTypeCreate" - } - } + "description": "Authentication required." }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "201": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineType" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Permission denied." }, - "400": { - "description": "Invalid machine type." + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/machine-types/{machine_type_id}/policy": { + "/api/v1/admin/makerspace/{makerspace_id}/api-client-scopes": { "get": { - "operationId": "api_v1_admin_makerspace_machine_types_policy_retrieve", - "summary": "Read machine-type reminder policy", + "operationId": "api_v1_admin_makerspace_api_client_scopes_retrieve", + "summary": "List API-client scope grant options", "parameters": [ - { - "in": "path", - "name": "machine_type_id", - "schema": { - "type": "integer" - }, - "required": true - }, { "in": "path", "name": "makerspace_id", @@ -23910,7 +23863,7 @@ } ], "tags": [ - "Admin machines" + "API clients" ], "security": [ { @@ -23922,69 +23875,71 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineTypePolicy" + "$ref": "#/components/schemas/ApiClientScopeCatalogResponse" } } }, "description": "" }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } - }, - "put": { - "operationId": "api_v1_admin_makerspace_machine_types_policy_update", - "summary": "Set machine-type reminder policy", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/api-clients": { + "get": { + "operationId": "api_v1_admin_makerspace_api_clients_list", + "summary": "List or create makerspace API clients", "parameters": [ { "in": "path", - "name": "machine_type_id", + "name": "makerspace_id", "schema": { "type": "integer" }, "required": true }, { - "in": "path", - "name": "makerspace_id", + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", "schema": { "type": "integer" - }, - "required": true + } } ], "tags": [ - "Admin machines" + "API clients" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MachineTypePolicy" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/MachineTypePolicy" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/MachineTypePolicy" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -23995,43 +23950,48 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineTypePolicy" + "$ref": "#/components/schemas/PaginatedApiClientList" } } }, "description": "" }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "423": { - "description": "Makerspace writes are frozen for tenant migration." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/machine-types/{id}": { - "patch": { - "operationId": "api_v1_admin_makerspace_machine_types_partial_update", - "summary": "Update a custom machine type", + }, + "post": { + "operationId": "api_v1_admin_makerspace_api_clients_create", + "summary": "List or create makerspace API clients", "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - }, { "in": "path", "name": "makerspace_id", @@ -24042,26 +24002,27 @@ } ], "tags": [ - "Admin machines" + "API clients" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedMachineTypeUpdate" + "$ref": "#/components/schemas/ApiClient" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedMachineTypeUpdate" + "$ref": "#/components/schemas/ApiClient" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedMachineTypeUpdate" + "$ref": "#/components/schemas/ApiClient" } } - } + }, + "required": true }, "security": [ { @@ -24069,32 +24030,53 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineType" + "$ref": "#/components/schemas/ApiClientCreateResponse" } } }, "description": "" }, - "400": { - "description": "Invalid or built-in machine type." + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "Machine management permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Machine type not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/machines": { + "/api/v1/admin/makerspace/{makerspace_id}/api-settings": { "get": { - "operationId": "api_v1_admin_makerspace_machines_retrieve", - "summary": "List machines in a makerspace", + "operationId": "api_v1_admin_makerspace_api_settings_retrieve", + "summary": "Retrieve API integration settings", "parameters": [ { "in": "path", @@ -24103,18 +24085,10 @@ "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "q", - "schema": { - "type": "string" - }, - "description": "Full-text search over name, location, firmware version and notes." } ], "tags": [ - "Admin machines" + "API clients" ], "security": [ { @@ -24126,17 +24100,26 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineListResponse" + "$ref": "#/components/schemas/ApiIntegrationSettings" } } }, "description": "" + }, + "401": { + "description": "Authentication required." + }, + "403": { + "description": "The actor lacks manage_makerspace here." + }, + "404": { + "description": "Makerspace not found, archived, not active, or outside the actor's scope." } } }, - "post": { - "operationId": "api_v1_admin_makerspace_machines_create", - "summary": "Create a machine", + "patch": { + "operationId": "api_v1_admin_makerspace_api_settings_partial_update", + "summary": "Update API integration settings", "parameters": [ { "in": "path", @@ -24148,27 +24131,26 @@ } ], "tags": [ - "Admin machines" + "API clients" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Machine" + "$ref": "#/components/schemas/PatchedApiIntegrationSettings" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/Machine" + "$ref": "#/components/schemas/PatchedApiIntegrationSettings" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/Machine" + "$ref": "#/components/schemas/PatchedApiIntegrationSettings" } } - }, - "required": true + } }, "security": [ { @@ -24176,26 +24158,35 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Machine" + "$ref": "#/components/schemas/ApiIntegrationSettings" } } }, "description": "" }, "400": { - "description": "Invalid machine details." + "description": "Field validation errors, keyed by field." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "description": "The actor lacks manage_makerspace here." + }, + "404": { + "description": "Makerspace not found, archived, not active, or outside the actor's scope." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/maintenance-overdue": { + "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients": { "get": { - "operationId": "api_v1_admin_makerspace_maintenance_overdue_list", - "summary": "List overdue maintenance in my machine scope", + "operationId": "api_v1_admin_makerspace_archive_recipients_list", + "summary": "List archive recipients for a makerspace", "parameters": [ { "in": "path", @@ -24204,19 +24195,10 @@ "type": "integer" }, "required": true - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } } ], "tags": [ - "Admin maintenance" + "Backup recipients" ], "security": [ { @@ -24228,31 +24210,61 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedOverdueScheduleList" + "type": "array", + "items": { + "$ref": "#/components/schemas/ArchiveRecipient" + } } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The recipient or lifecycle request is invalid." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The recipient does not exist in this makerspace." }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Authentication is required." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Manage-makerspace permission is required." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/membership-invitations": { + }, "post": { - "operationId": "api_v1_admin_makerspace_membership_invitations_create", - "description": "Requires manage_members in this makerspace; staff role administration and waiver publishing require manage_makerspace.", + "operationId": "api_v1_admin_makerspace_archive_recipients_create", + "description": "The decrypted challenge is a 32-byte nonce exchanged as canonical, unpadded base64url. Only the SHA-256 digest of the raw 32 nonce bytes is persisted.", + "summary": "Enroll an archive recipient and issue a custody challenge", "parameters": [ { "in": "path", @@ -24264,23 +24276,23 @@ } ], "tags": [ - "Admin memberships" + "Backup recipients" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Invitation" + "$ref": "#/components/schemas/ArchiveRecipientCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/Invitation" + "$ref": "#/components/schemas/ArchiveRecipientCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/Invitation" + "$ref": "#/components/schemas/ArchiveRecipientCreate" } } }, @@ -24296,7 +24308,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipRequest" + "$ref": "#/components/schemas/ArchiveRecipientChallenge" } } }, @@ -24306,118 +24318,78 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ArchiveRecipientError" } } }, - "description": "" + "description": "The recipient or lifecycle request is invalid." }, - "403": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ArchiveRecipientError" } } }, - "description": "" + "description": "The recipient does not exist in this makerspace." }, - "404": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ArchiveRecipientError" } } }, - "description": "" + "description": "The recipient or lifecycle request is invalid." }, - "409": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ArchiveRecipientError" } } }, - "description": "" + "description": "The age challenge could not be encrypted." }, - "423": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ArchiveRecipientError" } } }, - "description": "" + "description": "Authentication is required." }, - "503": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ArchiveRecipientError" } } }, - "description": "" + "description": "Manage-makerspace permission is required." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/modules": { - "get": { - "operationId": "api_v1_admin_makerspace_modules_retrieve", - "summary": "List module groups and their install state for a makerspace", + "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients/{id}/compromise": { + "post": { + "operationId": "api_v1_admin_makerspace_archive_recipients_compromise_create", + "summary": "Mark an archive recipient as compromised", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - } - ], - "tags": [ - "Platform" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "description": "Grouped module status." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/modules/install": { - "post": { - "operationId": "api_v1_admin_makerspace_modules_install_create", - "summary": "Install a module and everything it requires", - "parameters": [ { "in": "path", "name": "makerspace_id", @@ -24428,28 +24400,8 @@ } ], "tags": [ - "Platform" + "Backup recipients" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ModuleAction" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ModuleAction" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ModuleAction" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -24457,26 +24409,71 @@ ], "responses": { "200": { - "description": "Keys newly installed." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipient" + } + } + }, + "description": "" }, "400": { - "description": "Unknown module, or not shipped by this deployment." - }, - "403": { - "description": "Active superadmin access is required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The recipient or lifecycle request is invalid." }, "404": { - "description": "Makerspace not found, archived, not active, or hidden from superadmins by its own manager." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The recipient does not exist in this makerspace." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Manage-makerspace permission is required." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/modules/uninstall": { + "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients/{id}/reactivate": { "post": { - "operationId": "api_v1_admin_makerspace_modules_uninstall_create", - "description": "Clears the capability key only. Rows, uploads and history are retained and reinstalling restores every surface. Destroying the data is a separate, irreversible step (`purge_module_data`) that is deliberately CLI-only.", - "summary": "Uninstall a module, keeping its data", + "operationId": "api_v1_admin_makerspace_archive_recipients_reactivate_create", + "summary": "Reactivate a revoked archive recipient", "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -24487,28 +24484,8 @@ } ], "tags": [ - "Platform" + "Backup recipients" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ModuleAction" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ModuleAction" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ModuleAction" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -24516,25 +24493,72 @@ ], "responses": { "200": { - "description": "Keys uninstalled." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipient" + } + } + }, + "description": "" }, "400": { - "description": "Core module, or required by an installed module." - }, - "403": { - "description": "Active superadmin access is required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The recipient or lifecycle request is invalid." }, "404": { - "description": "Makerspace not found, archived, not active, or hidden from superadmins by its own manager." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The recipient does not exist in this makerspace." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Manage-makerspace permission is required." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/notification-destinations": { - "get": { - "operationId": "api_v1_admin_makerspace_notification_destinations_list", - "summary": "List or create chat notification destinations", + "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients/{id}/reissue-challenge": { + "post": { + "operationId": "api_v1_admin_makerspace_archive_recipients_reissue_challenge_create", + "description": "The decrypted 32-byte nonce is exchanged as canonical, unpadded base64url. The prior challenge stops verifying when this request commits.", + "summary": "Reissue an archive-recipient custody challenge", "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -24545,7 +24569,7 @@ } ], "tags": [ - "Makerspaces" + "Backup recipients" ], "security": [ { @@ -24557,30 +24581,78 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationDestination" - } + "$ref": "#/components/schemas/ArchiveRecipientChallenge" } } }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The recipient or lifecycle request is invalid." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The recipient does not exist in this makerspace." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The age challenge could not be encrypted." + }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Authentication is required." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Manage-makerspace permission is required." } } - }, + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients/{id}/revoke": { "post": { - "operationId": "api_v1_admin_makerspace_notification_destinations_create", - "summary": "List or create chat notification destinations", + "operationId": "api_v1_admin_makerspace_archive_recipients_revoke_create", + "summary": "Revoke an archive recipient", "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -24591,58 +24663,76 @@ } ], "tags": [ - "Makerspaces" + "Backup recipients" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationDestinationWrite" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/NotificationDestinationWrite" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/NotificationDestinationWrite" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationDestination" + "$ref": "#/components/schemas/ArchiveRecipient" } } }, "description": "" }, "400": { - "description": "Invalid destination." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The recipient or lifecycle request is invalid." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The recipient does not exist in this makerspace." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Manage-makerspace permission is required." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/notification-destinations/{destination_id}": { - "put": { - "operationId": "api_v1_admin_makerspace_notification_destinations_update", - "summary": "Update or delete a chat notification destination", + "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients/{id}/verify": { + "post": { + "operationId": "api_v1_admin_makerspace_archive_recipients_verify_create", + "description": "Submit the decrypted nonce as canonical unpadded base64url. It must decode to exactly 32 bytes; padding and non-canonical forms are refused.", + "summary": "Verify possession of an archive recipient", "parameters": [ { "in": "path", - "name": "destination_id", + "name": "id", "schema": { "type": "integer" }, @@ -24658,23 +24748,23 @@ } ], "tags": [ - "Makerspaces" + "Backup recipients" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationDestinationWrite" + "$ref": "#/components/schemas/ArchiveRecipientVerify" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/NotificationDestinationWrite" + "$ref": "#/components/schemas/ArchiveRecipientVerify" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/NotificationDestinationWrite" + "$ref": "#/components/schemas/ArchiveRecipientVerify" } } }, @@ -24690,66 +24780,79 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationDestination" + "$ref": "#/components/schemas/ArchiveRecipient" } } }, "description": "" }, "400": { - "description": "Invalid destination." - } - } - }, - "delete": { - "operationId": "api_v1_admin_makerspace_notification_destinations_destroy", - "summary": "Update or delete a chat notification destination", - "parameters": [ - { - "in": "path", - "name": "destination_id", - "schema": { - "type": "integer" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } }, - "required": true + "description": "The recipient or lifecycle request is invalid." }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } }, - "required": true - } - ], - "tags": [ - "Makerspaces" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "204": { - "description": "Destination removed." + "description": "The recipient or lifecycle request is invalid." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The recipient does not exist in this makerspace." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Verification attempts for this recipient are throttled." }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Authentication is required." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Manage-makerspace permission is required." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/notification-recipient-rules": { + "/api/v1/admin/makerspace/{makerspace_id}/archive-requests": { "get": { - "operationId": "api_v1_admin_makerspace_notification_recipient_rules_retrieve", - "summary": "Read or replace notification recipients", + "operationId": "api_v1_admin_makerspace_archive_requests_list", + "summary": "List archive requests for a makerspace", "parameters": [ { "in": "path", @@ -24761,7 +24864,7 @@ } ], "tags": [ - "Makerspaces" + "Admin makerspaces" ], "security": [ { @@ -24773,26 +24876,40 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecipientRulesResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/MakerspaceArchiveRequest" + } } } }, "description": "" }, - "401": { - "description": "Not signed in." - }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRequestError" + } + } + }, + "description": "" } } }, - "put": { - "operationId": "api_v1_admin_makerspace_notification_recipient_rules_update", - "summary": "Read or replace notification recipients", + "post": { + "operationId": "api_v1_admin_makerspace_archive_requests_create", + "summary": "Request makerspace archival", "parameters": [ { "in": "path", @@ -24804,23 +24921,23 @@ } ], "tags": [ - "Makerspaces" + "Admin makerspaces" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecipientRulesPut" + "$ref": "#/components/schemas/MakerspaceArchiveRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/RecipientRulesPut" + "$ref": "#/components/schemas/MakerspaceArchiveRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/RecipientRulesPut" + "$ref": "#/components/schemas/MakerspaceArchiveRequest" } } }, @@ -24832,31 +24949,72 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecipientRulesResponse" + "$ref": "#/components/schemas/MakerspaceArchiveRequest" } } }, "description": "" }, "400": { - "description": "Invalid recipient rule." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRequestValidationError" + } + } + }, + "description": "The reason was blank or too long." }, "403": { - "description": "Recipient-rule permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/notification-recipients": { - "get": { - "operationId": "api_v1_admin_makerspace_notification_recipients_list", - "description": "Space-manager control over which of the makerspace's managers receive the staff\nlifecycle emails. Toggling a manager off clears `receives_notifications` without\ntouching their access/role.", - "summary": "List or toggle staff email notification recipients", + "/api/v1/admin/makerspace/{makerspace_id}/archive-requests/{id}/withdraw": { + "post": { + "operationId": "api_v1_admin_makerspace_archive_requests_withdraw_create", + "summary": "Withdraw a pending makerspace archive request", "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -24867,7 +25025,7 @@ } ], "tags": [ - "Makerspaces" + "Admin makerspaces" ], "security": [ { @@ -24879,30 +25037,49 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationRecipient" - } + "$ref": "#/components/schemas/MakerspaceArchiveRequest" } } }, "description": "" }, - "401": { - "description": "Not signed in." - }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRequestError" + } + } + }, + "description": "" } } - }, - "patch": { - "operationId": "api_v1_admin_makerspace_notification_recipients_partial_update", - "description": "Space-manager control over which of the makerspace's managers receive the staff\nlifecycle emails. Toggling a manager off clears `receives_notifications` without\ntouching their access/role.", - "summary": "List or toggle staff email notification recipients", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/audit-verifications": { + "get": { + "operationId": "api_v1_admin_makerspace_audit_verifications_retrieve", + "summary": "List audit verification runs for one makerspace", "parameters": [ { "in": "path", @@ -24911,30 +25088,19 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + }, + "description": "Page number (24 runs per page)." } ], "tags": [ - "Makerspaces" + "Audit verification" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedNotificationRecipientsPatch" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedNotificationRecipientsPatch" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedNotificationRecipientsPatch" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -24945,77 +25111,58 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationRecipient" - } + "$ref": "#/components/schemas/VerificationRunPage" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditVerificationBadRequest" + } + } + }, + "description": "Invalid filters, `narrow_audit_range` or `audit_verification_scope_too_large`." }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditVerificationDetailError" + } + } + }, + "description": "Not authenticated." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/notification-rules": { - "get": { - "operationId": "api_v1_admin_makerspace_notification_rules_retrieve", - "summary": "List or update makerspace notification rules", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Makerspaces" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationRulesResponse" + "$ref": "#/components/schemas/AuditVerificationDetailError" } } }, - "description": "" - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "description": "Not active staff." }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditVerificationDetailError" + } + } + }, + "description": "Makerspace or run not in the caller's VIEW_AUDIT scope." } } }, - "patch": { - "operationId": "api_v1_admin_makerspace_notification_rules_partial_update", - "summary": "List or update makerspace notification rules", + "post": { + "operationId": "api_v1_admin_makerspace_audit_verifications_create", + "description": "Always asynchronous: 202 with the new or deduplicated run (`deduplicated`), then poll `status_url`. One active run per makerspace (409 `audit_verification_busy`); at most 10,000 selected rows and bounded expanded work (400); throttled per user (429).", + "summary": "Request asynchronous verification of the filtered audit rows", "parameters": [ { "in": "path", @@ -25027,23 +25174,23 @@ } ], "tags": [ - "Makerspaces" + "Audit verification" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedNotificationRulesPatch" + "$ref": "#/components/schemas/VerificationRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedNotificationRulesPatch" + "$ref": "#/components/schemas/VerificationRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedNotificationRulesPatch" + "$ref": "#/components/schemas/VerificationRequest" } } } @@ -25054,116 +25201,87 @@ } ], "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationRulesResponse" + "$ref": "#/components/schemas/VerificationAccepted" } } }, "description": "" }, - "400": { - "description": "Invalid notification rule or preference change." - }, - "404": { - "description": "Makerspace not found." - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/payment-documents": { - "get": { - "operationId": "api_v1_admin_makerspace_payment_documents_retrieve", - "summary": "List scoped payment documents", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditVerificationError" + } + } }, - "required": true + "description": "`audit_verification_busy`." }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer" - } - } - ], - "tags": [ - "Payments" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DocumentPage" + "$ref": "#/components/schemas/AuditVerificationDetailError" } } }, - "description": "" + "description": "Throttled." }, "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/AuditVerificationBadRequest" } } }, - "description": "" + "description": "Invalid filters, `narrow_audit_range` or `audit_verification_scope_too_large`." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/AuditVerificationDetailError" } } }, - "description": "" + "description": "Not authenticated." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/AuditVerificationDetailError" } } }, - "description": "" + "description": "Not active staff." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/AuditVerificationDetailError" } } }, - "description": "" + "description": "Makerspace or run not in the caller's VIEW_AUDIT scope." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/payment-documents/{document_id}/download-url": { + "/api/v1/admin/makerspace/{makerspace_id}/audit-verifications/{run_id}": { "get": { - "operationId": "api_v1_admin_makerspace_payment_documents_download_url_retrieve", - "summary": "Get a user-bound five-minute PDF download URL", + "operationId": "api_v1_admin_makerspace_audit_verifications_retrieve_2", + "summary": "Read one audit verification run's latest state and coverage", "parameters": [ { "in": "path", - "name": "document_id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -25171,7 +25289,7 @@ }, { "in": "path", - "name": "makerspace_id", + "name": "run_id", "schema": { "type": "integer" }, @@ -25179,7 +25297,7 @@ } ], "tags": [ - "Payments" + "Audit verification" ], "security": [ { @@ -25191,7 +25309,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DownloadGrant" + "$ref": "#/components/schemas/VerificationRun" } } }, @@ -25201,49 +25319,49 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/AuditVerificationBadRequest" } } }, - "description": "" + "description": "Invalid filters, `narrow_audit_range` or `audit_verification_scope_too_large`." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/AuditVerificationDetailError" } } }, - "description": "" + "description": "Not authenticated." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/AuditVerificationDetailError" } } }, - "description": "" + "description": "Not active staff." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/AuditVerificationDetailError" } } }, - "description": "" + "description": "Makerspace or run not in the caller's VIEW_AUDIT scope." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/payment-settings": { + "/api/v1/admin/makerspace/{makerspace_id}/backup-custody": { "get": { - "operationId": "api_v1_admin_makerspace_payment_settings_retrieve", - "summary": "Retrieve makerspace payment settings", + "operationId": "api_v1_admin_makerspace_backup_custody_retrieve", + "summary": "Get the makerspace's archive and tenant-exit custody status", "parameters": [ { "in": "path", @@ -25255,7 +25373,7 @@ } ], "tags": [ - "Admin payment settings" + "Backup recipients" ], "security": [ { @@ -25267,37 +25385,31 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MakerspacePaymentSettings" + "$ref": "#/components/schemas/BackupCustody" } } }, "description": "" }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaymentSettingsError" - } - } - }, - "description": "" + "400": { + "description": "The backup module is not installed for this makerspace." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaymentSettingsError" - } - } - }, - "description": "" + "description": "The requested resource does not exist in the actor's scope." + }, + "401": { + "description": "Authentication is required." + }, + "403": { + "description": "The authenticated actor is not authorized." } } - }, - "patch": { - "operationId": "api_v1_admin_makerspace_payment_settings_partial_update", - "summary": "Update makerspace payment settings", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/backups": { + "get": { + "operationId": "api_v1_admin_makerspace_backups_list", + "summary": "List makerspace backup archives", "parameters": [ { "in": "path", @@ -25309,27 +25421,8 @@ } ], "tags": [ - "Admin payment settings" + "Backup" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedMakerspacePaymentSettings" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedMakerspacePaymentSettings" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedMakerspacePaymentSettings" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -25340,50 +25433,32 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MakerspacePaymentSettings" + "type": "array", + "items": { + "$ref": "#/components/schemas/BackupArchive" + } } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Field errors, including a credential change blocked by a live session." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaymentSettingsError" - } - } - }, - "description": "" + "description": "The backup module is not installed for this makerspace." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaymentSettingsError" - } - } - }, - "description": "" + "description": "The requested resource does not exist in the actor's scope." + }, + "401": { + "description": "Authentication is required." + }, + "403": { + "description": "The authenticated actor is not authorized." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/payment-settings/connect/onboard": { + }, "post": { - "operationId": "api_v1_admin_makerspace_payment_settings_connect_onboard_create", - "summary": "Start Stripe Connect onboarding", + "operationId": "api_v1_admin_makerspace_backups_create", + "summary": "Request an age-encrypted makerspace backup", "parameters": [ { "in": "path", @@ -25395,7 +25470,7 @@ } ], "tags": [ - "Admin payment settings" + "Backup" ], "security": [ { @@ -25403,74 +25478,40 @@ } ], "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StripeConnectOnboarding" + "$ref": "#/components/schemas/BackupArchive" } } }, "description": "" }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaymentSettingsError" - } - } - }, - "description": "" + "400": { + "description": "The backup module is not installed for this makerspace." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaymentSettingsError" - } - } - }, - "description": "" + "description": "The requested resource does not exist in the actor's scope." }, "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaymentSettingsError" - } - } - }, - "description": "" + "description": "The backup worker is unavailable." + }, + "401": { + "description": "Authentication is required." + }, + "403": { + "description": "The authenticated actor is not authorized." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/payments": { + "/api/v1/admin/makerspace/{makerspace_id}/billing-settings": { "get": { - "operationId": "api_v1_admin_makerspace_payments_retrieve", - "description": "Requires view_finance. With for_recording=true, requires record_payment and returns only pending and offline-settled charges for counter receipt work.", - "summary": "List makerspace payments for reconciliation", + "operationId": "api_v1_admin_makerspace_billing_settings_retrieve", + "description": "Requires manage_makerspace. No payments module or credentials required; billing.reminders is a separate opt-in.", + "summary": "Read billing, receipt and accounting policy", "parameters": [ - { - "in": "query", - "name": "due_state", - "schema": { - "type": "string", - "enum": [ - "due_today", - "overdue", - "upcoming" - ] - } - }, - { - "in": "query", - "name": "for_recording", - "schema": { - "type": "boolean" - } - }, { "in": "path", "name": "makerspace_id", @@ -25478,53 +25519,6 @@ "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "ordering", - "schema": { - "type": "string" - }, - "description": "Allowlisted sort field; prefix with '-' for descending." - }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer" - }, - "description": "Page number, 24 rows per page." - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" - ] - } } ], "tags": [ @@ -25540,7 +25534,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaymentListResponse" + "$ref": "#/components/schemas/BillingSettings" } } }, @@ -25550,11 +25544,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaymentFilterError" + "$ref": "#/components/schemas/BillingPolicyError" } } }, - "description": "" + "description": "Invalid billing policy fields." }, "401": { "content": { @@ -25564,7 +25558,7 @@ } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { @@ -25574,7 +25568,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -25584,26 +25578,14 @@ } } }, - "description": "Payment or makerspace not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Payment is already terminal." + "description": "" } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/amend-settlement": { - "post": { - "operationId": "api_v1_admin_makerspace_payments_amend_settlement_create", - "description": "Requires record_payment or the existing scoped payment-subject authority. Appends a correction; the original receipt remains immutable.", - "summary": "Correct how a settled payment was received", + }, + "patch": { + "operationId": "api_v1_admin_makerspace_billing_settings_partial_update", + "description": "Requires manage_makerspace. Terms apply only to new charges. Reminders require billing.reminders, notifications and email; no online rail required.", + "summary": "Change billing, receipt and accounting policy", "parameters": [ { "in": "path", @@ -25612,14 +25594,6 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "payment_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ @@ -25629,21 +25603,20 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaymentOffline" + "$ref": "#/components/schemas/PatchedBillingSettings" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PaymentOffline" + "$ref": "#/components/schemas/PatchedBillingSettings" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PaymentOffline" + "$ref": "#/components/schemas/PatchedBillingSettings" } } - }, - "required": true + } }, "security": [ { @@ -25655,7 +25628,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaymentReconciliation" + "$ref": "#/components/schemas/BillingSettings" } } }, @@ -25665,11 +25638,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/BillingPolicyError" } } }, - "description": "Invalid payment request." + "description": "Invalid billing policy fields." }, "401": { "content": { @@ -25679,7 +25652,7 @@ } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { @@ -25689,7 +25662,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -25699,26 +25672,15 @@ } } }, - "description": "Payment or makerspace not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Payment is already terminal." + "description": "" } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/mark-offline": { - "post": { - "operationId": "api_v1_admin_makerspace_payments_mark_offline_create", - "description": "Requires record_payment or the existing scoped payment-subject authority. Appends an audited manual settlement receipt.", - "summary": "Mark a payment paid offline", + "/api/v1/admin/makerspace/{makerspace_id}/categories": { + "get": { + "operationId": "api_v1_admin_makerspace_categories_list", + "summary": "List or create inventory categories", "parameters": [ { "in": "path", @@ -25729,37 +25691,18 @@ "required": true }, { - "in": "path", - "name": "payment_id", + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", "schema": { "type": "integer" - }, - "required": true + } } ], "tags": [ - "Payments" + "Admin inventory" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaymentOffline" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PaymentOffline" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PaymentOffline" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -25770,70 +25713,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaymentReconciliation" + "$ref": "#/components/schemas/PaginatedCategoryAdminList" } } }, "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid payment request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Payment or makerspace not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Payment is already terminal." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/refund": { + }, "post": { - "operationId": "api_v1_admin_makerspace_payments_refund_create", - "description": "Sends money back through the provider that took it and records a Refund line on the payment. Only `paid_online` payments qualify; the sum of pending and succeeded refunds can never exceed the payment amount. The Payment row itself is immutable -- refund state lives on the Refund rows returned in `refunds`.", - "summary": "Refund a payment settled online, fully or partially", + "operationId": "api_v1_admin_makerspace_categories_create", + "summary": "List or create inventory categories", "parameters": [ { "in": "path", @@ -25842,34 +25732,26 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "payment_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Payments" + "Admin inventory" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaymentRefundRequest" + "$ref": "#/components/schemas/CategoryAdmin" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PaymentRefundRequest" + "$ref": "#/components/schemas/CategoryAdmin" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PaymentRefundRequest" + "$ref": "#/components/schemas/CategoryAdmin" } } }, @@ -25881,64 +25763,77 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaymentReconciliation" + "$ref": "#/components/schemas/CategoryAdmin" } } }, "description": "" + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/certification-types": { + "get": { + "operationId": "api_v1_admin_makerspace_certification_types_retrieve", + "summary": "List certification types in a makerspace", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true }, - "400": { + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + }, + "description": "Page number (24 rows per page)." + } + ], + "tags": [ + "Admin machines" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/CertificationTypePage" } } }, - "description": "Refund not allowed or over the balance." + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Payment or makerspace not found." - }, - "502": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "The provider rejected the refund." + "description": "Not found, not visible to this actor, or the surface is not enabled here." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/refund-offline": { + }, "post": { - "operationId": "api_v1_admin_makerspace_payments_refund_offline_create", - "description": "Requires active staff and record_payment. Records money already returned on a paid_offline charge, fully or partially, with method, optional financial reference and refunded_at. The receipt is append-only and audited; all pending/succeeded refunds share one balance. No provider call or payments module is required. The Payment remains unchanged.", - "summary": "Record an offline refund or release a cash loan deposit", + "operationId": "api_v1_admin_makerspace_certification_types_create", + "summary": "Create a certification type", "parameters": [ { "in": "path", @@ -25947,34 +25842,26 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "payment_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Payments" + "Admin machines" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaymentOfflineRefund" + "$ref": "#/components/schemas/CertificationType" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PaymentOfflineRefund" + "$ref": "#/components/schemas/CertificationType" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PaymentOfflineRefund" + "$ref": "#/components/schemas/CertificationType" } } }, @@ -25986,64 +25873,38 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaymentReconciliation" + "$ref": "#/components/schemas/CertificationType" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ManualRefundError" - } - } - }, - "description": "Invalid receipt, status or refund balance." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Authentication required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Record payment and active status required." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Payment not found in the permitted makerspace." + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "423": { + "description": "Makerspace frozen for tenant migration." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/refunds/{refund_id}/amend": { - "post": { - "operationId": "api_v1_admin_makerspace_payments_refunds_amend_create", - "description": "Requires record_payment. Replaces method, reference and returned date on the effective receipt; amount/currency and the original payment never change. No second refund event or transfer occurs.", - "summary": "Append a corrected manual refund receipt", + "/api/v1/admin/makerspace/{makerspace_id}/containers": { + "get": { + "operationId": "api_v1_admin_makerspace_containers_list", + "summary": "List containers", "parameters": [ { "in": "path", @@ -26054,45 +25915,27 @@ "required": true }, { - "in": "path", - "name": "payment_id", + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", "schema": { "type": "integer" - }, - "required": true + } }, { - "in": "path", - "name": "refund_id", + "name": "page_size", + "required": false, + "in": "query", + "description": "Number of results to return per page.", "schema": { "type": "integer" - }, - "required": true + } } ], "tags": [ - "Payments" + "Containers" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaymentRefundAmend" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PaymentRefundAmend" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PaymentRefundAmend" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -26103,7 +25946,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaymentReconciliation" + "$ref": "#/components/schemas/PaginatedBoxList" } } }, @@ -26113,7 +25956,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ManualRefundError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -26150,13 +25993,10 @@ "description": "" } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/release-deposit": { + }, "post": { - "operationId": "api_v1_admin_makerspace_payments_release_deposit_create", - "description": "Requires record_payment. Only a paid_offline loan deposit on a returned or closed_with_issue loan is eligible. Records actual money returned; never offsets fees or calls the rail.", - "summary": "Record a deposit returned after a loan closes", + "operationId": "api_v1_admin_makerspace_containers_create", + "summary": "Create container", "parameters": [ { "in": "path", @@ -26165,34 +26005,26 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "payment_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Payments" + "Containers" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaymentOfflineRefund" + "$ref": "#/components/schemas/Box" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PaymentOfflineRefund" + "$ref": "#/components/schemas/Box" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PaymentOfflineRefund" + "$ref": "#/components/schemas/Box" } } }, @@ -26204,11 +26036,11 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaymentReconciliation" + "$ref": "#/components/schemas/Box" } } }, @@ -26218,7 +26050,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ManualRefundError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -26257,10 +26089,10 @@ } } }, - "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/waive": { + "/api/v1/admin/makerspace/{makerspace_id}/cover": { "post": { - "operationId": "api_v1_admin_makerspace_payments_waive_create", - "summary": "Waive a payment", + "operationId": "api_v1_admin_makerspace_cover_create", + "summary": "Create a makerspace public image upload URL", "parameters": [ { "in": "path", @@ -26269,93 +26101,64 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "payment_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Payments" + "Admin makerspaces" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadRequest" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaymentReconciliation" + "$ref": "#/components/schemas/PublicImageUploadResponse" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid payment request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Authentication required." + "description": "Invalid image upload request." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." + "description": "The actor lacks manage_makerspace here." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Payment or makerspace not found." + "description": "Makerspace not found, archived, not active, or outside the actor's scope." }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Payment is already terminal." + "503": { + "description": "Public image storage is unavailable." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/payments/bulk/mark-offline": { - "post": { - "operationId": "api_v1_admin_makerspace_payments_bulk_mark_offline_create", - "description": "Requires record_payment or the existing scoped payment-subject authority for every charge in the batch.", - "summary": "Mark payments paid offline in one transaction", + }, + "put": { + "operationId": "api_v1_admin_makerspace_cover_update", + "summary": "Attach an uploaded public image to a makerspace", "parameters": [ { "in": "path", @@ -26367,23 +26170,23 @@ } ], "tags": [ - "Payments" + "Admin makerspaces" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaymentBulkOffline" + "$ref": "#/components/schemas/PublicImageAttachRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PaymentBulkOffline" + "$ref": "#/components/schemas/PublicImageAttachRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PaymentBulkOffline" + "$ref": "#/components/schemas/PublicImageAttachRequest" } } }, @@ -26399,36 +26202,102 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PaymentReconciliation" - } + "$ref": "#/components/schemas/Makerspace" } } }, "description": "" }, "400": { + "description": "Invalid image object key or size." + }, + "403": { + "description": "The actor lacks manage_makerspace here." + }, + "404": { + "description": "Makerspace not found, archived, not active, or outside the actor's scope." + }, + "503": { + "description": "Public image storage is unavailable." + } + } + }, + "delete": { + "operationId": "api_v1_admin_makerspace_cover_destroy", + "summary": "Clear a makerspace public image", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin makerspaces" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/Makerspace" } } }, - "description": "Invalid payment request." + "description": "" }, - "401": { + "403": { + "description": "The actor lacks manage_makerspace here." + }, + "404": { + "description": "Makerspace not found, archived, not active, or outside the actor's scope." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/dashboard": { + "get": { + "operationId": "api_v1_admin_makerspace_dashboard_retrieve", + "description": "Preserves the existing counters and adds report-backed widgets with chart specs. Widgets require their own actions and source modules plus reports; machine data uses assigned machine types. Handover-only actors receive queue tiles only. Native staff sessions use local membership authority, without organization grants.", + "summary": "Staff operations counts, action-scoped widgets and handover queues", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Dashboard" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/Dashboard" } } }, - "description": "Authentication required." + "description": "" }, - "403": { + "401": { "content": { "application/json": { "schema": { @@ -26436,9 +26305,9 @@ } } }, - "description": "Permission denied." + "description": "Authentication required." }, - "404": { + "403": { "content": { "application/json": { "schema": { @@ -26446,9 +26315,9 @@ } } }, - "description": "Payment or makerspace not found." + "description": "Permission denied." }, - "409": { + "404": { "content": { "application/json": { "schema": { @@ -26456,17 +26325,17 @@ } } }, - "description": "Payment is already terminal." + "description": "Makerspace not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/payments/bulk/waive": { - "post": { - "operationId": "api_v1_admin_makerspace_payments_bulk_waive_create", - "summary": "Waive payments in one transaction", - "parameters": [ - { + "/api/v1/admin/makerspace/{makerspace_id}/data-exports": { + "get": { + "operationId": "api_v1_admin_makerspace_data_exports_list", + "summary": "List makerspace data-export jobs", + "parameters": [ + { "in": "path", "name": "makerspace_id", "schema": { @@ -26476,27 +26345,66 @@ } ], "tags": [ - "Payments" + "Data exports" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DataExportJob" + } + } + } + }, + "description": "" + }, + "403": { + "description": "MANAGE_MAKERSPACE is required." + } + } + }, + "post": { + "operationId": "api_v1_admin_makerspace_data_exports_create", + "summary": "Request a redacted makerspace data export", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Data exports" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaymentBulkAction" + "$ref": "#/components/schemas/DataExportCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PaymentBulkAction" + "$ref": "#/components/schemas/DataExportCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PaymentBulkAction" + "$ref": "#/components/schemas/DataExportCreate" } } - }, - "required": true + } }, "security": [ { @@ -26504,87 +26412,41 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PaymentReconciliation" - } + "$ref": "#/components/schemas/DataExportJob" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid payment request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Authentication required." + "description": "Invalid fidelity or quota/module policy failure." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Payment or makerspace not found." + "description": "MANAGE_MAKERSPACE is required." }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Payment is already terminal." + "429": { + "description": "Export creation rate limit exceeded." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/pending-requests": { + "/api/v1/admin/makerspace/{makerspace_id}/data-exports/{job_id}": { "get": { - "operationId": "api_v1_admin_makerspace_pending_requests_list", - "summary": "List pending borrow requests", + "operationId": "api_v1_admin_makerspace_data_exports_retrieve", + "summary": "Poll a makerspace data-export job", "parameters": [ { - "name": "id", - "in": "query", - "required": false, - "description": "Select one result within the authorized list.", + "in": "path", + "name": "job_id", "schema": { - "type": "integer", - "minimum": 1, - "maximum": 9223372036854775807 - } + "type": "string", + "format": "uuid" + }, + "required": true }, { "in": "path", @@ -26593,37 +26455,10 @@ "type": "integer" }, "required": true - }, - { - "name": "ordering", - "required": false, - "in": "query", - "description": "Which field to use when ordering the results.", - "schema": { - "type": "string" - } - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } - }, - { - "name": "search", - "required": false, - "in": "query", - "description": "A search term (requested-for, requester name/email).", - "schema": { - "type": "string" - } } ], "tags": [ - "Admin requests" + "Data exports" ], "security": [ { @@ -26635,60 +26470,35 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedAdminRequestList" + "$ref": "#/components/schemas/DataExportJob" } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Authentication required." - }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." + "description": "MANAGE_MAKERSPACE is required." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Not found." + "description": "Export job not found in this makerspace." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/presence-sessions/current": { - "get": { - "operationId": "api_v1_admin_makerspace_presence_sessions_current_list", - "description": "Requires manage_members in this makerspace.", + "/api/v1/admin/makerspace/{makerspace_id}/data-exports/{job_id}/download-url": { + "post": { + "operationId": "api_v1_admin_makerspace_data_exports_download_url_create", + "summary": "Issue a short-lived one-use export download URL", "parameters": [ + { + "in": "path", + "name": "job_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -26699,7 +26509,7 @@ } ], "tags": [ - "Admin makerspaces" + "Data exports" ], "security": [ { @@ -26711,50 +26521,27 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PresenceRoster" - } + "$ref": "#/components/schemas/DataExportDownloadUrl" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid input." - }, - "401": { - "description": "Authentication required." + "description": "Export is not available." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Membership permission required." + "description": "MANAGE_MAKERSPACE is required; PORTABLE additionally requires source superadmin." }, "404": { - "description": "Makerspace not found." - }, - "429": { - "description": "Rate limit exceeded." + "description": "Export job not found in this makerspace." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/printer-jobs": { + "/api/v1/admin/makerspace/{makerspace_id}/device-printer-bindings": { "get": { - "operationId": "api_v1_admin_makerspace_printer_jobs_list", + "operationId": "api_v1_admin_makerspace_device_printer_bindings_list", "parameters": [ { "in": "path", @@ -26787,7 +26574,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedJobList" + "$ref": "#/components/schemas/PaginatedBindingList" } } }, @@ -26866,7 +26653,7 @@ } }, "post": { - "operationId": "api_v1_admin_makerspace_printer_jobs_create", + "operationId": "api_v1_admin_makerspace_device_printer_bindings_create", "parameters": [ { "in": "path", @@ -26884,17 +26671,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JobCreate" + "$ref": "#/components/schemas/BindingCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/JobCreate" + "$ref": "#/components/schemas/BindingCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/JobCreate" + "$ref": "#/components/schemas/BindingCreate" } } }, @@ -26910,7 +26697,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Job" + "$ref": "#/components/schemas/Binding" } } }, @@ -26989,30 +26776,31 @@ } } }, - "/api/v1/admin/makerspace/{makerspace_id}/problem-reports/{id}/resolve": { - "post": { - "operationId": "api_v1_admin_makerspace_problem_reports_resolve_create", - "summary": "Resolve a public problem report", + "/api/v1/admin/makerspace/{makerspace_id}/devices": { + "get": { + "operationId": "api_v1_admin_makerspace_devices_list", + "summary": "List a makerspace's devices", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, "required": true }, { - "in": "path", - "name": "makerspace_id", + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", "schema": { "type": "integer" - }, - "required": true + } } ], "tags": [ - "Analytics" + "Devices" ], "security": [ { @@ -27024,29 +26812,68 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/PaginatedDeviceList" } } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Invalid request or module disabled." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Not found." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Tenant frozen for migration." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/problem-reports/{id}/triage": { + }, "post": { - "operationId": "api_v1_admin_makerspace_problem_reports_triage_create", - "summary": "Triage a public problem report", + "operationId": "api_v1_admin_makerspace_devices_create", + "summary": "Create a pending device and its first enrollment", "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - }, { "in": "path", "name": "makerspace_id", @@ -27057,23 +26884,23 @@ } ], "tags": [ - "Analytics" + "Devices" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProblemReportTriage" + "$ref": "#/components/schemas/DeviceCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ProblemReportTriage" + "$ref": "#/components/schemas/DeviceCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ProblemReportTriage" + "$ref": "#/components/schemas/DeviceCreate" } } }, @@ -27085,24 +26912,72 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProblemReportTriageResponse" + "$ref": "#/components/schemas/DeviceCreated" } } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Invalid request or module disabled." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Not found." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Tenant frozen for migration." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/profile": { + "/api/v1/admin/makerspace/{makerspace_id}/direct-loan-members": { "get": { - "operationId": "api_v1_admin_makerspace_profile_retrieve", - "description": "Core configuration: usable even with every optional module switched off.", - "summary": "Read location and opening hours", + "operationId": "api_v1_admin_makerspace_direct_loan_members_list", "parameters": [ { "in": "path", @@ -27111,10 +26986,19 @@ "type": "integer" }, "required": true + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } } ], "tags": [ - "Admin makerspaces" + "api" ], "security": [ { @@ -27126,27 +27010,19 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MakerspaceProfile" + "$ref": "#/components/schemas/PaginatedDirectLoanMemberList" } } }, "description": "" - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." } } - }, - "patch": { - "operationId": "api_v1_admin_makerspace_profile_partial_update", - "description": "Requires manage_makerspace. Empty hours mean always open. Dated exceptions replace weekly hours. Currency updates the existing payment default, never currencies on existing charges.", - "summary": "Update location and opening hours", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/direct-loans": { + "get": { + "operationId": "api_v1_admin_makerspace_direct_loans_list", + "summary": "List direct handout loans", "parameters": [ { "in": "path", @@ -27155,30 +27031,20 @@ "type": "integer" }, "required": true + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } } ], "tags": [ - "Admin makerspaces" + "Admin requests" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedMakerspaceProfile" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedMakerspaceProfile" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedMakerspaceProfile" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -27189,7 +27055,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MakerspaceProfile" + "$ref": "#/components/schemas/PaginatedDirectLoanList" } } }, @@ -27199,48 +27065,57 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Invalid request." }, - "423": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileGateError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant writes are frozen for migration." + "description": "Authentication required." }, - "503": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileGateError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The source gate cannot prove writes are safe." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "description": "Permission denied." }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/provision-subdomain": { + }, "post": { - "operationId": "api_v1_admin_makerspace_provision_subdomain_create", - "summary": "Provision a platform subdomain for a makerspace", + "operationId": "api_v1_admin_makerspace_direct_loans_create", + "summary": "Issue direct handout without public request", "parameters": [ { "in": "path", @@ -27252,23 +27127,23 @@ } ], "tags": [ - "Admin makerspaces" + "Admin requests" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProvisionSubdomainRequest" + "$ref": "#/components/schemas/DirectLoanIssue" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ProvisionSubdomainRequest" + "$ref": "#/components/schemas/DirectLoanIssue" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ProvisionSubdomainRequest" + "$ref": "#/components/schemas/DirectLoanIssue" } } }, @@ -27280,11 +27155,11 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Makerspace" + "$ref": "#/components/schemas/DirectLoan" } } }, @@ -27294,39 +27169,69 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProvisionSubdomainValidationError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid or unavailable platform subdomain label." + "description": "Invalid request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HostingError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Active superadmin access is required." + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HostingError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace not found." + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Service unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/qr-print-batches": { + "/api/v1/admin/makerspace/{makerspace_id}/email-logs": { "get": { - "operationId": "api_v1_admin_makerspace_qr_print_batches_list", - "summary": "List QR print batches", + "operationId": "api_v1_admin_makerspace_email_logs_list", + "summary": "List makerspace email delivery logs", "parameters": [ { "in": "path", @@ -27344,10 +27249,25 @@ "schema": { "type": "integer" } + }, + { + "in": "query", + "name": "recent_days", + "schema": { + "type": "integer" + }, + "description": "Limit to the last 1–365 days." + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string" + } } ], "tags": [ - "QR print batches" + "Email logs" ], "security": [ { @@ -27359,18 +27279,40 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedQrPrintBatchList" + "$ref": "#/components/schemas/PaginatedEmailLogList" } } }, "description": "" + }, + "400": { + "description": "Invalid status filter." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Makerspace not found." } } - }, + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/email-logs/{id}/retry": { "post": { - "operationId": "api_v1_admin_makerspace_qr_print_batches_create", - "summary": "Create QR print batch", + "operationId": "api_v1_admin_makerspace_email_logs_retry_create", + "summary": "Retry a failed makerspace email", "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -27381,83 +27323,37 @@ } ], "tags": [ - "QR print batches" + "Email logs" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/QrPrintBatchCreate" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/QrPrintBatchCreate" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/QrPrintBatchCreate" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QrPrintBatch" + "$ref": "#/components/schemas/EmailLog" } } }, "description": "" + }, + "400": { + "description": "Email log cannot be retried." + }, + "404": { + "description": "Email log not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/reports/{report_key}/export": { + "/api/v1/admin/makerspace/{makerspace_id}/email-templates": { "get": { - "operationId": "api_v1_admin_makerspace_reports_export_retrieve", - "summary": "Export report", + "operationId": "api_v1_admin_makerspace_email_templates_list", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "format", - "schema": { - "type": "string", - "enum": [ - "csv", - "xlsx" - ] - } - }, - { - "in": "query", - "name": "grain", - "schema": { - "type": "string", - "enum": [ - "day", - "month", - "week" - ] - } - }, { "in": "path", "name": "makerspace_id", @@ -27465,103 +27361,10 @@ "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "months", - "schema": { - "type": "integer" - }, - "description": "Retention anniversary in months (1-120); default 3, 6 and 12." - }, - { - "in": "path", - "name": "report_key", - "schema": { - "type": "string", - "enum": [ - "accounting-journal", - "active-loans", - "audit-trail", - "booking-utilization", - "cash-book", - "certification-coverage", - "communications-health", - "community-engagement", - "damaged-lost", - "damaged-missing", - "event-attendance", - "evidence-compliance", - "fablab-health", - "import-quality", - "inventory-control", - "loan-throughput", - "machine-service", - "machine-usage", - "maintenance-activity", - "member-activity", - "member-retention", - "membership-revenue", - "module-operational-health", - "most-lent", - "payment-cash-book", - "payment-reconciliation", - "payments-ledger", - "printer-service", - "procurement-performance", - "qr-scans", - "recently-added", - "returns", - "revenue-over-time", - "summary", - "taken-items", - "top-borrowers" - ] - }, - "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" - ] - } } ], "tags": [ - "Reports" + "Email templates" ], "security": [ { @@ -27570,69 +27373,50 @@ ], "responses": { "200": { - "content": { - "text/csv": { - "schema": { - "type": "string" - } - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { - "schema": { - "type": "string", - "format": "binary" - } - } - }, - "description": "" - }, - "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "type": "array", + "items": { + "$ref": "#/components/schemas/EmailTemplateListItem" + } } } }, - "description": "Invalid report request." + "description": "" }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Authentication required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Permission denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Makerspace or report not found." + "description": "Not found, not visible to this actor, or the surface is not enabled here." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/reports/catalog": { + "/api/v1/admin/makerspace/{makerspace_id}/email-templates/{stream}/{audience}/{key}": { "get": { - "operationId": "api_v1_admin_makerspace_reports_catalog_retrieve", - "summary": "List makerspace report catalog", + "operationId": "api_v1_admin_makerspace_email_templates_retrieve", "parameters": [ + { + "in": "path", + "name": "audience", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "key", + "schema": { + "type": "string" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -27640,10 +27424,18 @@ "type": "integer" }, "required": true + }, + { + "in": "path", + "name": "stream", + "schema": { + "type": "string" + }, + "required": true } ], "tags": [ - "Reports" + "Email templates" ], "security": [ { @@ -27655,110 +27447,84 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportCatalog" + "$ref": "#/components/schemas/EmailTemplateDetail" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Invalid report request." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Authentication required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Permission denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Makerspace or report not found." + "description": "Not found, not visible to this actor, or the surface is not enabled here." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/request-history": { - "get": { - "operationId": "api_v1_admin_makerspace_request_history_list", - "summary": "List terminal request history (returned / rejected / closed with issue)", + }, + "patch": { + "operationId": "api_v1_admin_makerspace_email_templates_partial_update", "parameters": [ - { - "name": "id", - "in": "query", - "required": false, - "description": "Select one result within the authorized list.", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 9223372036854775807 - } - }, { "in": "path", - "name": "makerspace_id", + "name": "audience", "schema": { - "type": "integer" + "type": "string" }, "required": true }, { - "name": "ordering", - "required": false, - "in": "query", - "description": "Which field to use when ordering the results.", + "in": "path", + "name": "key", "schema": { "type": "string" - } + }, + "required": true }, { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", + "in": "path", + "name": "makerspace_id", "schema": { "type": "integer" - } + }, + "required": true }, { - "name": "search", - "required": false, - "in": "query", - "description": "A search term (requested-for, requester name/email).", + "in": "path", + "name": "stream", "schema": { "type": "string" - } + }, + "required": true } ], "tags": [ - "Admin requests" + "Email templates" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -27769,59 +27535,47 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedAdminRequestList" + "$ref": "#/components/schemas/EmailTemplateDetail" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid request." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Authentication required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Not found." + "description": "Not found, not visible to this actor, or the surface is not enabled here." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/return-policy": { - "get": { - "operationId": "api_v1_admin_makerspace_return_policy_retrieve", + "/api/v1/admin/makerspace/{makerspace_id}/email-templates/{stream}/{audience}/{key}/reset": { + "post": { + "operationId": "api_v1_admin_makerspace_email_templates_reset_create", "parameters": [ + { + "in": "path", + "name": "audience", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "key", + "schema": { + "type": "string" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -27829,10 +27583,18 @@ "type": "integer" }, "required": true + }, + { + "in": "path", + "name": "stream", + "schema": { + "type": "string" + }, + "required": true } ], "tags": [ - "Admin makerspaces" + "Email templates" ], "security": [ { @@ -27844,77 +27606,132 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReturnPolicy" + "$ref": "#/components/schemas/EmailTemplateDetail" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } + "description": "Not found, not visible to this actor, or the surface is not enabled here." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/email-templates/{stream}/{audience}/{key}/types/{machine_type_id}": { + "get": { + "operationId": "api_v1_admin_makerspace_email_templates_types_retrieve", + "parameters": [ + { + "in": "path", + "name": "audience", + "schema": { + "type": "string" }, - "description": "" + "required": true }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } + { + "in": "path", + "name": "key", + "schema": { + "type": "string" }, - "description": "" + "required": true }, - "503": { + { + "in": "path", + "name": "machine_type_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "stream", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Email templates" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/EmailTemplateDetail" } } }, "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." } } }, "patch": { - "operationId": "api_v1_admin_makerspace_return_policy_partial_update", + "operationId": "api_v1_admin_makerspace_email_templates_types_partial_update", "parameters": [ + { + "in": "path", + "name": "audience", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "key", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "machine_type_id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -27922,26 +27739,34 @@ "type": "integer" }, "required": true + }, + { + "in": "path", + "name": "stream", + "schema": { + "type": "string" + }, + "required": true } ], "tags": [ - "Admin makerspaces" + "Email templates" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedReturnPolicy" + "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedReturnPolicy" + "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedReturnPolicy" + "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" } } } @@ -27956,79 +27781,109 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReturnPolicy" + "$ref": "#/components/schemas/EmailTemplateDetail" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } + "description": "Not found, not visible to this actor, or the surface is not enabled here." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/email-templates/{stream}/{audience}/{key}/types/{machine_type_id}/reset": { + "post": { + "operationId": "api_v1_admin_makerspace_email_templates_types_reset_create", + "parameters": [ + { + "in": "path", + "name": "audience", + "schema": { + "type": "string" }, - "description": "" + "required": true }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } + { + "in": "path", + "name": "key", + "schema": { + "type": "string" }, - "description": "" + "required": true }, - "503": { + { + "in": "path", + "name": "machine_type_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "stream", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Email templates" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/EmailTemplateDetail" } } }, "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/setup-state": { - "get": { - "operationId": "api_v1_admin_makerspace_setup_state_retrieve", - "summary": "Read derived makerspace setup progress", + "/api/v1/admin/makerspace/{makerspace_id}/email-templates/preview": { + "post": { + "operationId": "api_v1_admin_makerspace_email_templates_preview_create", "parameters": [ { "in": "path", @@ -28040,8 +27895,28 @@ } ], "tags": [ - "Makerspaces" + "Email templates" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailTemplatePreviewRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/EmailTemplatePreviewRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/EmailTemplatePreviewRequest" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -28052,12 +27927,15 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetupState" + "$ref": "#/components/schemas/EmailTemplatePreviewResponse" } } }, "description": "" }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, "401": { "description": "Not signed in." }, @@ -28070,10 +27948,10 @@ } } }, - "/api/v1/admin/makerspace/{makerspace_id}/space-pack": { + "/api/v1/admin/makerspace/{makerspace_id}/integration-health": { "get": { - "operationId": "api_v1_admin_makerspace_space_pack_retrieve", - "summary": "Export configuration-only space pack", + "operationId": "api_v1_admin_makerspace_integration_health_retrieve", + "summary": "Get makerspace integration health", "parameters": [ { "in": "path", @@ -28085,7 +27963,7 @@ } ], "tags": [ - "Admin makerspaces" + "Integration health" ], "security": [ { @@ -28097,77 +27975,164 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SpacePack" + "$ref": "#/components/schemas/IntegrationHealth" } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PackError" - } - } + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Not allowed to manage this makerspace." + }, + "404": { + "description": "Makerspace not found." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/inventory": { + "get": { + "operationId": "api_v1_admin_makerspace_inventory_list", + "summary": "List or create inventory products", + "parameters": [ + { + "in": "query", + "name": "archived", + "schema": { + "type": "string" }, - "description": "" + "description": "`true` for archived, `false` for active; `all` or omitted includes both." }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PackError" - } - } + { + "name": "id", + "in": "query", + "required": false, + "description": "Select one result within the authorized list.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + } + }, + { + "in": "query", + "name": "low_stock", + "schema": { + "type": "string" }, - "description": "" + "description": "`true` to list items at or below 20% of total." }, - "403": { + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "query", + "name": "ordering", + "schema": { + "type": "string" + }, + "description": "Allowlisted sort field; prefix with '-' for descending." + }, + { + "in": "query", + "name": "out_of_stock", + "schema": { + "type": "string", + "enum": [ + "false", + "true" + ] + }, + "description": "`true` for exactly zero available, matching the dashboard tile." + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + }, + "description": "Page number, 24 rows per page." + }, + { + "name": "page_size", + "required": false, + "in": "query", + "description": "Number of results to return per page.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Full-text search over name, storage location, tracking mode and description (\"phrases\", -exclusions, typo-tolerant on the name), or a category name." + } + ], + "tags": [ + "Admin inventory" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackError" + "$ref": "#/components/schemas/PaginatedInventoryProductAdminList" } } }, "description": "" }, - "404": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "409": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "423": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "503": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -28176,9 +28141,8 @@ } }, "post": { - "operationId": "api_v1_admin_makerspace_space_pack_create", - "description": "Dry-run defaults true. Apply needs base_checksum from preview. Modules remain superadmin-only. Unrelated entries are retained.", - "summary": "Preview or atomically apply a space pack", + "operationId": "api_v1_admin_makerspace_inventory_create", + "summary": "List or create inventory products", "parameters": [ { "in": "path", @@ -28190,23 +28154,23 @@ } ], "tags": [ - "Admin makerspaces" + "Admin inventory" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackImport" + "$ref": "#/components/schemas/InventoryProductAdminCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PackImport" + "$ref": "#/components/schemas/InventoryProductAdminCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PackImport" + "$ref": "#/components/schemas/InventoryProductAdminCreate" } } }, @@ -28218,11 +28182,11 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackPreview" + "$ref": "#/components/schemas/InventoryProductAdmin" } } }, @@ -28232,7 +28196,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -28242,7 +28206,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -28252,7 +28216,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -28262,37 +28226,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackError" - } - } - }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PackError" - } - } - }, - "description": "" - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PackError" - } - } - }, - "description": "" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PackError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -28301,11 +28235,48 @@ } } }, - "/api/v1/admin/makerspace/{makerspace_id}/stock-transfers": { + "/api/v1/admin/makerspace/{makerspace_id}/inventory/export": { "get": { - "operationId": "api_v1_admin_makerspace_stock_transfers_list", - "summary": "List stock transfers", + "operationId": "api_v1_admin_makerspace_inventory_export_retrieve", + "summary": "Export inventory products as CSV or XLSX", "parameters": [ + { + "in": "query", + "name": "archived", + "schema": { + "type": "string", + "enum": [ + "all", + "false", + "true" + ] + } + }, + { + "in": "query", + "name": "format", + "schema": { + "type": "string", + "enum": [ + "csv", + "xlsx" + ] + } + }, + { + "in": "query", + "name": "ids", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "low_stock", + "schema": { + "type": "boolean" + } + }, { "in": "path", "name": "makerspace_id", @@ -28315,17 +28286,27 @@ "required": true }, { - "name": "page", - "required": false, "in": "query", - "description": "A page number within the paginated result set.", + "name": "out_of_stock", "schema": { - "type": "integer" + "type": "string", + "enum": [ + "false", + "true" + ] + }, + "description": "Exactly zero available, matching the dashboard list." + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" } } ], "tags": [ - "Stock transfers" + "Admin inventory" ], "security": [ { @@ -28335,22 +28316,21 @@ "responses": { "200": { "content": { - "application/json": { + "text/csv": { "schema": { - "$ref": "#/components/schemas/PaginatedStockTransferList" + "type": "string" + } + }, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "schema": { + "type": "string", + "format": "binary" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GenericObject" - } - } - }, "description": "Invalid request." }, "401": { @@ -28363,10 +28343,12 @@ "description": "Not found." } } - }, + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/inventory/import/apply": { "post": { - "operationId": "api_v1_admin_makerspace_stock_transfers_create", - "summary": "Create stock transfer", + "operationId": "api_v1_admin_makerspace_inventory_import_apply_create", + "summary": "Apply inventory bulk import", "parameters": [ { "in": "path", @@ -28378,23 +28360,90 @@ } ], "tags": [ - "Stock transfers" + "Bulk import" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StockTransferCreate" + "$ref": "#/components/schemas/BulkImportPreview" + }, + "examples": { + "PreviewInventoryRows": { + "value": { + "rows": [ + { + "name": "Soldering Iron", + "total_quantity": 10, + "available_quantity": 8, + "is_public": true + } + ], + "mapping": { + "name": "name", + "total_quantity": "total_quantity" + } + }, + "summary": "Preview inventory rows" + } } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/StockTransferCreate" + "$ref": "#/components/schemas/BulkImportPreview" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/StockTransferCreate" + "$ref": "#/components/schemas/BulkImportPreview" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "description": "Import application result." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/inventory/import/jobs": { + "post": { + "operationId": "api_v1_admin_makerspace_inventory_import_jobs_create", + "summary": "Create an async inventory bulk import job", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Bulk import" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkImportJobCreate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/BulkImportJobCreate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/BulkImportJobCreate" } } }, @@ -28410,38 +28459,28 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StockTransfer" + "$ref": "#/components/schemas/BulkImportJob" } } }, "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GenericObject" - } - } - }, - "description": "Invalid request." - }, - "401": { - "description": "Authentication credentials were not provided." - }, - "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/stocktake-policy": { + "/api/v1/admin/makerspace/{makerspace_id}/inventory/import/jobs/{job_id}": { "get": { - "operationId": "api_v1_admin_makerspace_stocktake_policy_retrieve", + "operationId": "api_v1_admin_makerspace_inventory_import_jobs_retrieve", + "summary": "Get async inventory bulk import job status", "parameters": [ + { + "in": "path", + "name": "job_id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -28452,7 +28491,7 @@ } ], "tags": [ - "api" + "Bulk import" ], "security": [ { @@ -28464,43 +28503,146 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Policy" + "$ref": "#/components/schemas/BulkImportJob" } } }, "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/inventory/import/preview": { + "post": { + "operationId": "api_v1_admin_makerspace_inventory_import_preview_create", + "summary": "Preview inventory bulk import", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Bulk import" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkImportPreview" + }, + "examples": { + "PreviewInventoryRows": { + "value": { + "rows": [ + { + "name": "Soldering Iron", + "total_quantity": 10, + "available_quantity": 8, + "is_public": true + } + ], + "mapping": { + "name": "name", + "total_quantity": "total_quantity" + } + }, + "summary": "Preview inventory rows" } } }, - "description": "" + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/BulkImportPreview" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/BulkImportPreview" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "description": "Import preview with row errors." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/inventory/reorder-draft": { + "post": { + "operationId": "api_v1_admin_makerspace_inventory_reorder_draft_create", + "summary": "Create or reuse a procurement reorder draft", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin inventory" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReorderDraft" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ReorderDraft" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ReorderDraft" + } + } }, - "403": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ToBuyItem" } } }, "description": "" }, - "404": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ToBuyItem" } } }, "description": "" }, - "423": { + "400": { "content": { "application/json": { "schema": { @@ -28510,7 +28652,7 @@ }, "description": "" }, - "429": { + "401": { "content": { "application/json": { "schema": { @@ -28520,7 +28662,7 @@ }, "description": "" }, - "503": { + "403": { "content": { "application/json": { "schema": { @@ -28530,21 +28672,23 @@ }, "description": "" }, - "400": { + "404": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" } } - }, - "put": { - "operationId": "api_v1_admin_makerspace_stocktake_policy_update", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/inventory/reorder-suggestions": { + "get": { + "operationId": "api_v1_admin_makerspace_inventory_reorder_suggestions_list", + "summary": "List products at their reorder points", "parameters": [ { "in": "path", @@ -28553,30 +28697,20 @@ "type": "integer" }, "required": true + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } } ], "tags": [ - "api" + "Admin inventory" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Policy" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/Policy" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Policy" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -28587,33 +28721,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Policy" - } - } - }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaginatedReorderSuggestionList" } } }, "description": "" }, - "404": { + "400": { "content": { "application/json": { "schema": { @@ -28623,7 +28737,7 @@ }, "description": "" }, - "423": { + "401": { "content": { "application/json": { "schema": { @@ -28633,7 +28747,7 @@ }, "description": "" }, - "429": { + "403": { "content": { "application/json": { "schema": { @@ -28643,7 +28757,7 @@ }, "description": "" }, - "503": { + "404": { "content": { "application/json": { "schema": { @@ -28652,24 +28766,15 @@ } }, "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - }, - "description": "" } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/stocktake-schedules": { + "/api/v1/admin/makerspace/{makerspace_id}/kiosk-replays/": { "get": { - "operationId": "api_v1_admin_makerspace_stocktake_schedules_list", + "operationId": "api_v1_admin_makerspace_kiosk_replays_list", + "description": "Historical review remains available with inventory.kiosk disabled. No channel credentials or command payload are returned.", + "summary": "List failed handovers visible under current per-operation actions", "parameters": [ { "in": "path", @@ -28687,10 +28792,32 @@ "schema": { "type": "integer" } + }, + { + "in": "query", + "name": "resolution", + "schema": { + "type": "string", + "enum": [ + "all", + "resolved", + "unresolved" + ] + } + }, + { + "in": "query", + "name": "state", + "schema": { + "type": "string", + "enum": [ + "failed" + ] + } } ], "tags": [ - "api" + "Admin kiosk recovery" ], "security": [ { @@ -28702,13 +28829,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedScheduleList" + "$ref": "#/components/schemas/PaginatedReplayRecoveryList" } } }, "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { @@ -28716,9 +28843,9 @@ } } }, - "description": "" + "description": "Invalid filter/resolution or disabled required module." }, - "403": { + "401": { "content": { "application/json": { "schema": { @@ -28726,9 +28853,9 @@ } } }, - "description": "" + "description": "Authentication required." }, - "404": { + "403": { "content": { "application/json": { "schema": { @@ -28736,9 +28863,9 @@ } } }, - "description": "" + "description": "Account, current action or origin refused." }, - "423": { + "404": { "content": { "application/json": { "schema": { @@ -28746,9 +28873,9 @@ } } }, - "description": "" + "description": "Failure is unavailable in your scope." }, - "429": { + "409": { "content": { "application/json": { "schema": { @@ -28756,9 +28883,9 @@ } } }, - "description": "" + "description": "Resolution/replacement conflict." }, - "503": { + "423": { "content": { "application/json": { "schema": { @@ -28766,23 +28893,15 @@ } } }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - }, - "description": "" + "description": "Tenant source gate closed." } } - }, - "post": { - "operationId": "api_v1_admin_makerspace_stocktake_schedules_create", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/kiosk-stations/": { + "get": { + "operationId": "api_v1_admin_makerspace_kiosk_stations_list", + "summary": "List kiosk Stations", "parameters": [ { "in": "path", @@ -28794,45 +28913,28 @@ } ], "tags": [ - "api" + "Admin kiosk stations" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Schedule" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/Schedule" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Schedule" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Schedule" + "type": "array", + "items": { + "$ref": "#/components/schemas/KioskStation" + } } } }, "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { @@ -28840,7 +28942,7 @@ } } }, - "description": "" + "description": "Feature disabled or invalid request." }, "403": { "content": { @@ -28850,7 +28952,7 @@ } } }, - "description": "" + "description": "Makerspace access or step-up denied." }, "404": { "content": { @@ -28860,9 +28962,9 @@ } } }, - "description": "" + "description": "Station not found." }, - "423": { + "409": { "content": { "application/json": { "schema": { @@ -28870,7 +28972,7 @@ } } }, - "description": "" + "description": "Rotate the PIN instead of revealing." }, "429": { "content": { @@ -28880,7 +28982,7 @@ } } }, - "description": "" + "description": "Request rate limit exceeded." }, "503": { "content": { @@ -28890,34 +28992,14 @@ } } }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - }, - "description": "" + "description": "Credential secrets are unavailable." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/stocktake-schedules/{id}": { - "get": { - "operationId": "api_v1_admin_makerspace_stocktake_schedules_retrieve", + }, + "post": { + "operationId": "api_v1_admin_makerspace_kiosk_stations_create", + "summary": "Create a kiosk Station and its first PIN", "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - }, { "in": "path", "name": "makerspace_id", @@ -28928,25 +29010,45 @@ } ], "tags": [ - "api" + "Admin kiosk stations" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KioskStationCreate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/KioskStationCreate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/KioskStationCreate" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Schedule" + "$ref": "#/components/schemas/KioskStationSecret" } } }, "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { @@ -28954,7 +29056,7 @@ } } }, - "description": "" + "description": "Feature disabled or invalid request." }, "403": { "content": { @@ -28964,7 +29066,7 @@ } } }, - "description": "" + "description": "Makerspace access or step-up denied." }, "404": { "content": { @@ -28974,9 +29076,9 @@ } } }, - "description": "" + "description": "Station not found." }, - "423": { + "409": { "content": { "application/json": { "schema": { @@ -28984,7 +29086,7 @@ } } }, - "description": "" + "description": "Rotate the PIN instead of revealing." }, "429": { "content": { @@ -28994,7 +29096,7 @@ } } }, - "description": "" + "description": "Request rate limit exceeded." }, "503": { "content": { @@ -29004,32 +29106,16 @@ } } }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - }, - "description": "" + "description": "Credential secrets are unavailable." } } - }, - "patch": { - "operationId": "api_v1_admin_makerspace_stocktake_schedules_partial_update", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/kit-checklist-preview": { + "post": { + "operationId": "api_v1_admin_makerspace_kit_checklist_preview_create", + "summary": "Preview required issue or return kit components", "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - }, { "in": "path", "name": "makerspace_id", @@ -29040,23 +29126,23 @@ } ], "tags": [ - "api" + "Admin requests" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedSchedule" + "$ref": "#/components/schemas/KitPreviewInput" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedSchedule" + "$ref": "#/components/schemas/KitPreviewInput" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedSchedule" + "$ref": "#/components/schemas/KitPreviewInput" } } } @@ -29071,13 +29157,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Schedule" + "$ref": "#/components/schemas/KitPreview" } } }, "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { @@ -29087,7 +29173,7 @@ }, "description": "" }, - "403": { + "401": { "content": { "application/json": { "schema": { @@ -29097,7 +29183,7 @@ }, "description": "" }, - "404": { + "403": { "content": { "application/json": { "schema": { @@ -29107,7 +29193,7 @@ }, "description": "" }, - "423": { + "404": { "content": { "application/json": { "schema": { @@ -29126,52 +29212,89 @@ } }, "description": "" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - }, - "description": "" } } - }, - "delete": { - "operationId": "api_v1_admin_makerspace_stocktake_schedules_destroy", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/ledger": { + "get": { + "operationId": "api_v1_admin_makerspace_ledger_retrieve", + "summary": "List outstanding inventory loans", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, "required": true }, { - "in": "path", - "name": "makerspace_id", + "in": "query", + "name": "overdue", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "page", "schema": { "type": "integer" - }, - "required": true + } + }, + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "search", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "sort", + "schema": { + "type": "string", + "enum": [ + "-due", + "-holder", + "-item_name", + "-makerspace_id", + "-quantity", + "-since", + "-source", + "due", + "holder", + "item_name", + "makerspace_id", + "quantity", + "since", + "source" + ] + } + }, + { + "in": "query", + "name": "source", + "schema": { + "type": "string", + "enum": [ + "direct", + "reviewed", + "self_checkout" + ] + } } ], "tags": [ - "api" + "Ledger" ], "security": [ { @@ -29179,75 +29302,11 @@ } ], "responses": { - "204": { - "description": "No response body" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "400": { + "200": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/components/schemas/LedgerResponse" } } }, @@ -29256,11 +29315,22 @@ } } }, - "/api/v1/admin/makerspace/{makerspace_id}/stocktakes": { + "/api/v1/admin/makerspace/{makerspace_id}/ledger/export": { "get": { - "operationId": "api_v1_admin_makerspace_stocktakes_list", - "summary": "List stocktakes", + "operationId": "api_v1_admin_makerspace_ledger_export_retrieve", + "summary": "Export outstanding inventory loans", "parameters": [ + { + "in": "query", + "name": "format", + "schema": { + "type": "string", + "enum": [ + "csv", + "xlsx" + ] + } + }, { "in": "path", "name": "makerspace_id", @@ -29270,32 +29340,57 @@ "required": true }, { - "name": "page", - "required": false, "in": "query", - "description": "A page number within the paginated result set.", + "name": "overdue", "schema": { - "type": "integer" + "type": "boolean" } }, { "in": "query", - "name": "status", + "name": "search", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "sort", "schema": { "type": "string", "enum": [ - "applied", - "approved", - "cancelled", - "completed", - "counting", - "draft" + "-due", + "-holder", + "-item_name", + "-makerspace_id", + "-quantity", + "-since", + "-source", + "due", + "holder", + "item_name", + "makerspace_id", + "quantity", + "since", + "source" + ] + } + }, + { + "in": "query", + "name": "source", + "schema": { + "type": "string", + "enum": [ + "direct", + "reviewed", + "self_checkout" ] } } ], "tags": [ - "Stocktake" + "Ledger" ], "security": [ { @@ -29305,39 +29400,36 @@ "responses": { "200": { "content": { - "application/json": { + "text/csv": { "schema": { - "$ref": "#/components/schemas/PaginatedStocktakeList" + "type": "string" } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { + }, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { "schema": { - "$ref": "#/components/schemas/GenericObject" + "type": "string", + "format": "binary" } } }, - "description": "Invalid request or stocktake state." - }, - "401": { - "description": "Authentication credentials were not provided." - }, - "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." + "description": "" } } - }, + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/loans/{id}/renew": { "post": { - "operationId": "api_v1_admin_makerspace_stocktakes_create", - "summary": "Create stocktake", + "operationId": "api_v1_admin_makerspace_loans_renew_create", + "summary": "Extend an issued loan within its policy", "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -29348,24 +29440,19 @@ } ], "tags": [ - "Stocktake" + "Loans" ], "requestBody": { "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StocktakeCreate" - } - }, - "application/x-www-form-urlencoded": { + "type": { "schema": { - "$ref": "#/components/schemas/StocktakeCreate" + "type": "object", + "additionalProperties": {}, + "description": "Unspecified request body" } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/StocktakeCreate" - } + "properties": { + "schema": {} } } }, @@ -29375,11 +29462,11 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Stocktake" + "$ref": "#/components/schemas/LoanRenewal" } } }, @@ -29389,28 +29476,49 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GenericObject" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request or stocktake state." + "description": "" }, "401": { - "description": "Authentication credentials were not provided." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "Permission denied." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Not found." - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/subdomain-request": { - "get": { - "operationId": "api_v1_admin_makerspace_subdomain_request_list", - "summary": "List platform subdomain requests for a makerspace", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/logo": { + "post": { + "operationId": "api_v1_admin_makerspace_logo_create", + "summary": "Create a makerspace public image upload URL", "parameters": [ { "in": "path", @@ -29419,61 +29527,64 @@ "type": "integer" }, "required": true - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } } ], "tags": [ - "Admin hosting" + "Admin makerspaces" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadRequest" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedSubdomainRequestList" + "$ref": "#/components/schemas/PublicImageUploadResponse" } } }, "description": "" }, + "400": { + "description": "Invalid image upload request." + }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SubdomainRequestError" - } - } - }, - "description": "" + "description": "The actor lacks manage_makerspace here." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SubdomainRequestError" - } - } - }, - "description": "" + "description": "Makerspace not found, archived, not active, or outside the actor's scope." + }, + "503": { + "description": "Public image storage is unavailable." } } }, - "post": { - "operationId": "api_v1_admin_makerspace_subdomain_request_create", - "summary": "Request a platform subdomain for a makerspace", + "put": { + "operationId": "api_v1_admin_makerspace_logo_update", + "summary": "Attach an uploaded public image to a makerspace", "parameters": [ { "in": "path", @@ -29485,23 +29596,23 @@ } ], "tags": [ - "Admin hosting" + "Admin makerspaces" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SubdomainRequest" + "$ref": "#/components/schemas/PublicImageAttachRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/SubdomainRequest" + "$ref": "#/components/schemas/PublicImageAttachRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/SubdomainRequest" + "$ref": "#/components/schemas/PublicImageAttachRequest" } } }, @@ -29513,53 +29624,33 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SubdomainRequest" + "$ref": "#/components/schemas/Makerspace" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SubdomainRequestError" - } - } - }, - "description": "" + "description": "Invalid image object key or size." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SubdomainRequestError" - } - } - }, - "description": "" + "description": "The actor lacks manage_makerspace here." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SubdomainRequestError" - } - } - }, - "description": "" + "description": "Makerspace not found, archived, not active, or outside the actor's scope." + }, + "503": { + "description": "Public image storage is unavailable." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/disclosure-approvals": { - "get": { - "operationId": "api_v1_admin_makerspace_tenant_migration_disclosure_approvals_list", - "summary": "List disclosure approvals", + }, + "delete": { + "operationId": "api_v1_admin_makerspace_logo_destroy", + "summary": "Clear a makerspace public image", "parameters": [ { "in": "path", @@ -29571,7 +29662,7 @@ } ], "tags": [ - "Tenant migration" + "Admin makerspaces" ], "security": [ { @@ -29583,61 +29674,41 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ClosureApproval" - } + "$ref": "#/components/schemas/Makerspace" } } }, "description": "" }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Authentication required." - }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Superadmin access required." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Throttle limit exceeded." + "description": "The actor lacks manage_makerspace here." }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Unexpected migration failure." + "404": { + "description": "Makerspace not found, archived, not active, or outside the actor's scope." } } - }, - "post": { - "operationId": "api_v1_admin_makerspace_tenant_migration_disclosure_approvals_create", - "summary": "Approve each identity in one exact disclosure closure", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/machine-service-report": { + "get": { + "operationId": "api_v1_admin_makerspace_machine_service_report_retrieve", + "summary": "Retrieve makerspace machine-service report", "parameters": [ + { + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "machine_type", + "schema": { + "type": "string" + } + }, { "in": "path", "name": "makerspace_id", @@ -29645,42 +29716,30 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } } ], "tags": [ - "Tenant migration" + "Admin machine service" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ClosureApprovalCreate" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ClosureApprovalCreate" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ClosureApprovalCreate" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ClosureApproval" + "$ref": "#/components/schemas/MachineServiceReportResponse" } } }, @@ -29690,79 +29749,29 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FieldValidationError" - } - } - }, - "description": "Field-keyed validation errors." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "State conflict." + "description": "Invalid request." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Authentication required." + "description": "Authentication credentials were not provided." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Superadmin access required." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Throttle limit exceeded." + "description": "Machine management permission required." }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Unexpected migration failure." + "404": { + "description": "Not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/disclosure-approvals/{approval_id}/revoke": { - "post": { - "operationId": "api_v1_admin_makerspace_tenant_migration_disclosure_approvals_revoke_create", - "summary": "Revoke a disclosure approval", + "/api/v1/admin/makerspace/{makerspace_id}/machine-type-pricing": { + "get": { + "operationId": "api_v1_admin_makerspace_machine_type_pricing_retrieve", + "summary": "List makerspace machine-type pricing", "parameters": [ - { - "in": "path", - "name": "approval_id", - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true - }, { "in": "path", "name": "makerspace_id", @@ -29773,7 +29782,7 @@ } ], "tags": [ - "Tenant migration" + "Admin machines" ], "security": [ { @@ -29785,70 +29794,31 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ClosureApproval" + "$ref": "#/components/schemas/MachineTypePricingList" } } }, "description": "" }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Not found." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Authentication required." - }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Superadmin access required." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Throttle limit exceeded." - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Unexpected migration failure." + "description": "Space-manager identity required." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/disclosure-closure": { - "get": { - "operationId": "api_v1_admin_makerspace_tenant_migration_disclosure_closure_retrieve", - "summary": "Compute the pending PORTABLE disclosure closure", + "/api/v1/admin/makerspace/{makerspace_id}/machine-type-pricing/{machine_type_id}": { + "put": { + "operationId": "api_v1_admin_makerspace_machine_type_pricing_update", + "summary": "Set makerspace machine-type pricing", "parameters": [ + { + "in": "path", + "name": "machine_type_id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -29859,8 +29829,28 @@ } ], "tags": [ - "Tenant migration" + "Admin machines" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MachineTypePricingSet" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MachineTypePricingSet" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MachineTypePricingSet" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -29871,59 +29861,28 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PendingClosure" + "$ref": "#/components/schemas/MachineTypePricing" } } }, "description": "" }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Authentication required." + "400": { + "description": "Invalid price." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Superadmin access required." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Throttle limit exceeded." + "description": "Space-manager identity required." }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Unexpected migration failure." + "404": { + "description": "Machine type not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/exports": { + "/api/v1/admin/makerspace/{makerspace_id}/machine-types": { "get": { - "operationId": "api_v1_admin_makerspace_tenant_migration_exports_list", - "summary": "List PORTABLE migration export jobs", + "operationId": "api_v1_admin_makerspace_machine_types_list", + "summary": "List machine types for a makerspace", "parameters": [ { "in": "path", @@ -29935,7 +29894,7 @@ } ], "tags": [ - "Tenant migration" + "Admin machines" ], "security": [ { @@ -29949,58 +29908,18 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/MigrationExportJob" + "$ref": "#/components/schemas/MachineTypeAccess" } } } }, "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Superadmin access required." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Throttle limit exceeded." - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Unexpected migration failure." } } }, "post": { - "operationId": "api_v1_admin_makerspace_tenant_migration_exports_create", - "summary": "Create a target-recipient-encrypted PORTABLE export", + "operationId": "api_v1_admin_makerspace_machine_types_create", + "summary": "Create a custom machine type", "parameters": [ { "in": "path", @@ -30012,23 +29931,23 @@ } ], "tags": [ - "Tenant migration" + "Admin machines" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MigrationExportCreate" + "$ref": "#/components/schemas/MachineTypeCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MigrationExportCreate" + "$ref": "#/components/schemas/MachineTypeCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MigrationExportCreate" + "$ref": "#/components/schemas/MachineTypeCreate" } } }, @@ -30040,93 +29959,32 @@ } ], "responses": { - "202": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MigrationExportJob" + "$ref": "#/components/schemas/MachineType" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FieldValidationError" - } - } - }, - "description": "Field-keyed validation errors." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "State conflict." - }, - "503": { - "description": "Migration worker unavailable." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Superadmin access required." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Throttle limit exceeded." - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Unexpected migration failure." + "description": "Invalid machine type." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/exports/{job_id}": { + "/api/v1/admin/makerspace/{makerspace_id}/machine-types/{machine_type_id}/policy": { "get": { - "operationId": "api_v1_admin_makerspace_tenant_migration_exports_retrieve", - "summary": "Read a PORTABLE migration export job", + "operationId": "api_v1_admin_makerspace_machine_types_policy_retrieve", + "summary": "Read machine-type reminder policy", "parameters": [ { "in": "path", - "name": "job_id", + "name": "machine_type_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true }, @@ -30140,7 +29998,7 @@ } ], "tags": [ - "Tenant migration" + "Admin machines" ], "security": [ { @@ -30152,76 +30010,35 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MigrationExportJob" + "$ref": "#/components/schemas/MachineTypePolicy" } } }, "description": "" }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Not found." + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Authentication required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Superadmin access required." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Throttle limit exceeded." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Unexpected migration failure." + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/exports/{job_id}/download-url": { - "post": { - "operationId": "api_v1_admin_makerspace_tenant_migration_exports_download_url_create", - "summary": "Issue the one-use PORTABLE archive download URL", + }, + "put": { + "operationId": "api_v1_admin_makerspace_machine_types_policy_update", + "summary": "Set machine-type reminder policy", "parameters": [ { "in": "path", - "name": "job_id", + "name": "machine_type_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true }, @@ -30235,8 +30052,27 @@ } ], "tags": [ - "Tenant migration" + "Admin machines" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MachineTypePolicy" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MachineTypePolicy" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MachineTypePolicy" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -30247,86 +30083,40 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DataExportDownloadUrl" + "$ref": "#/components/schemas/MachineTypePolicy" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "State conflict." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Not found." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Authentication required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Superadmin access required." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Throttle limit exceeded." + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Unexpected migration failure." + "423": { + "description": "Makerspace writes are frozen for tenant migration." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/exports/{job_id}/quiesce": { - "post": { - "operationId": "api_v1_admin_makerspace_tenant_migration_exports_quiesce_create", - "summary": "Reassert a completed export's source gate lease", + "/api/v1/admin/makerspace/{makerspace_id}/machine-types/{id}": { + "patch": { + "operationId": "api_v1_admin_makerspace_machine_types_partial_update", + "summary": "Update a custom machine type", "parameters": [ { "in": "path", - "name": "job_id", + "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true }, @@ -30340,8 +30130,27 @@ } ], "tags": [ - "Tenant migration" + "Admin machines" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedMachineTypeUpdate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedMachineTypeUpdate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedMachineTypeUpdate" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -30352,79 +30161,129 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CutoverOutcome" + "$ref": "#/components/schemas/MachineType" } } }, "description": "" }, + "400": { + "description": "Invalid or built-in machine type." + }, + "403": { + "description": "Machine management permission required." + }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } + "description": "Machine type not found." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/machines": { + "get": { + "operationId": "api_v1_admin_makerspace_machines_retrieve", + "summary": "List machines in a makerspace", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" }, - "description": "Not found." + "required": true }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } + { + "in": "query", + "name": "q", + "schema": { + "type": "string" }, - "description": "State conflict." - }, - "401": { + "description": "Full-text search over name, location, firmware version and notes." + } + ], + "tags": [ + "Admin machines" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/MachineListResponse" } } }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } + "description": "" + } + } + }, + "post": { + "operationId": "api_v1_admin_makerspace_machines_create", + "summary": "Create a machine", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Machine" } }, - "description": "Superadmin access required." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Machine" } }, - "description": "Throttle limit exceeded." + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Machine" + } + } }, - "500": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/Machine" } } }, - "description": "Unexpected migration failure." + "description": "" + }, + "400": { + "description": "Invalid machine details." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/pairings/{pairing_id}/archive-source": { - "post": { - "operationId": "api_v1_admin_makerspace_tenant_migration_pairings_archive_source_create", - "summary": "Archive the quiesced source and issue its signed cutover receipt", + "/api/v1/admin/makerspace/{makerspace_id}/maintenance-overdue": { + "get": { + "operationId": "api_v1_admin_makerspace_maintenance_overdue_list", + "summary": "List overdue maintenance in my machine scope", "parameters": [ { "in": "path", @@ -30435,17 +30294,17 @@ "required": true }, { - "in": "path", - "name": "pairing_id", + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", "schema": { - "type": "string", - "format": "uuid" - }, - "required": true + "type": "integer" + } } ], "tags": [ - "Tenant migration" + "Admin maintenance" ], "security": [ { @@ -30457,79 +30316,31 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CutoverOutcome" + "$ref": "#/components/schemas/PaginatedOverdueScheduleList" } } }, "description": "" }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "State conflict." + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Authentication required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Superadmin access required." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Throttle limit exceeded." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Unexpected migration failure." + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/pairings/{pairing_id}/recover": { + "/api/v1/admin/makerspace/{makerspace_id}/membership-invitations": { "post": { - "operationId": "api_v1_admin_makerspace_tenant_migration_pairings_recover_create", - "summary": "Recover an archived source with the target abort receipt", + "operationId": "api_v1_admin_makerspace_membership_invitations_create", + "description": "Requires manage_members in this makerspace; staff role administration and waiver publishing require manage_makerspace.", "parameters": [ { "in": "path", @@ -30538,35 +30349,26 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "pairing_id", - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true } ], "tags": [ - "Tenant migration" + "Admin memberships" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CutoverReceiptRequest" + "$ref": "#/components/schemas/Invitation" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/CutoverReceiptRequest" + "$ref": "#/components/schemas/Invitation" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/CutoverReceiptRequest" + "$ref": "#/components/schemas/Invitation" } } }, @@ -30578,11 +30380,11 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CutoverOutcome" + "$ref": "#/components/schemas/MembershipRequest" } } }, @@ -30592,79 +30394,78 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FieldValidationError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Field-keyed validation errors." + "description": "" }, - "404": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Not found." + "description": "" }, - "409": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "State conflict." + "description": "" }, - "401": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, - "403": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Superadmin access required." + "description": "" }, - "429": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Throttle limit exceeded." + "description": "" }, - "500": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected migration failure." + "description": "" } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/usage": { + "/api/v1/admin/makerspace/{makerspace_id}/messaging-settings": { "get": { - "operationId": "api_v1_admin_makerspace_usage_retrieve", - "summary": "Read own makerspace usage and effective limits", + "operationId": "api_v1_admin_makerspace_messaging_settings_retrieve", "parameters": [ { "in": "path", @@ -30676,7 +30477,7 @@ } ], "tags": [ - "Admin makerspaces" + "Messaging" ], "security": [ { @@ -30688,7 +30489,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MakerspaceUsage" + "$ref": "#/components/schemas/MessagingSettings" } } }, @@ -30707,12 +30508,9 @@ "description": "Not found, not visible to this actor, or the surface is not enabled here." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/verify-domain": { - "post": { - "operationId": "api_v1_admin_makerspace_verify_domain_create", - "summary": "Verify a makerspace custom domain", + }, + "patch": { + "operationId": "api_v1_admin_makerspace_messaging_settings_partial_update", "parameters": [ { "in": "path", @@ -30724,8 +30522,27 @@ } ], "tags": [ - "Admin makerspaces" + "Messaging" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedMessagingSettings" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedMessagingSettings" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedMessagingSettings" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -30736,35 +30553,41 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DomainVerificationResponse" + "$ref": "#/components/schemas/MessagingSettings" } } }, "description": "" }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, "403": { - "description": "Missing makerspace management permission." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "description": "Makerspace not found." + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "409": { + "description": "Conflicts with the current state." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." + }, + "503": { + "description": "A required dependency is unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/warranties": { - "get": { - "operationId": "api_v1_admin_makerspace_warranties_list", - "summary": "List warranty coverage for a makerspace", + "/api/v1/admin/makerspace/{makerspace_id}/messaging-settings/test": { + "post": { + "operationId": "api_v1_admin_makerspace_messaging_settings_test_create", + "description": "Diagnostic to your own verified, consented phone. The chosen event must pass feature/matrix, channel, consent, suppression, template and quota gates.", "parameters": [ - { - "in": "query", - "name": "expires_before", - "schema": { - "type": "string", - "format": "date" - }, - "description": "Only include hosts with warranty expiry on or before this date." - }, { "in": "path", "name": "makerspace_id", @@ -30772,106 +30595,84 @@ "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "missing_docs", - "schema": { - "type": "boolean" - }, - "description": "Only include hosts with no warranty documents or no warranty record." - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } - }, - { - "name": "page_size", - "required": false, - "in": "query", - "description": "Number of results to return per page.", - "schema": { - "type": "integer" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "active", - "expired", - "expiring_soon", - "unknown" - ] - }, - "description": "Filter rows by computed warranty status." } ], "tags": [ - "Admin warranty" + "Messaging" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessagingTest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MessagingTest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MessagingTest" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedWarrantyReportRowList" + "$ref": "#/components/schemas/MessagingTestResponse" } } }, "description": "" }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." + }, + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." } } } }, - "/api/v1/admin/makerspaces": { + "/api/v1/admin/makerspace/{makerspace_id}/modules": { "get": { - "operationId": "api_v1_admin_makerspaces_list", - "summary": "List makerspaces the actor has a staff surface in", + "operationId": "api_v1_admin_makerspace_modules_retrieve", + "summary": "List module groups and their install state for a makerspace", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Admin makerspaces" + "Platform" ], "security": [ { @@ -30880,45 +30681,52 @@ ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Makerspace" - } - } - } - }, - "description": "" + "description": "Grouped module status." + }, + "401": { + "description": "Not signed in." }, "403": { - "description": "Not an active staff account." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." } } - }, + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/modules/install": { "post": { - "operationId": "api_v1_admin_makerspaces_create", - "description": "Optional install_profile_id applies a saved pack. creation_setup applies profile, hours, modules and first staff invitation in the same transaction; explicit setup overrides the pack. Invitation delivery follows commit and returns a private fallback link.", - "summary": "Create a makerspace (superadmin only)", + "operationId": "api_v1_admin_makerspace_modules_install_create", + "summary": "Install a module and everything it requires", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Admin makerspaces" + "Platform" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Makerspace" + "$ref": "#/components/schemas/ModuleAction" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/Makerspace" + "$ref": "#/components/schemas/ModuleAction" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/Makerspace" + "$ref": "#/components/schemas/ModuleAction" } } }, @@ -30930,85 +30738,26 @@ } ], "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Makerspace" - } - } - }, - "description": "" + "200": { + "description": "Keys newly installed." }, "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {}, - "properties": { - "theme_config": { - "oneOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "object", - "required": [ - "code", - "messages" - ], - "properties": { - "code": { - "type": "string", - "enum": [ - "theme_contrast" - ] - }, - "messages": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - ] - } - } - } - } - }, - "description": "Field validation errors. Contrast errors have theme_config: {code: 'theme_contrast', messages: string[]} with mode, failing pair, measured ratio and required ratio." + "description": "Unknown module, or not shipped by this deployment." }, - "401": { - "description": "Not signed in." + "403": { + "description": "Active superadmin access is required." }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "409": { - "description": "Conflicts with the current state." - }, - "423": { - "description": "Makerspace writes are frozen for tenant migration." - }, - "503": { - "description": "A required dependency is unavailable." - }, - "403": { - "description": "Only a superadmin can create a makerspace." + "description": "Makerspace not found, archived, not active, or hidden from superadmins by its own manager." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/booking-machine-types/": { - "get": { - "operationId": "api_v1_admin_makerspaces_booking_machine_types_retrieve", - "summary": "List machine type choices for booking spaces", + "/api/v1/admin/makerspace/{makerspace_id}/modules/uninstall": { + "post": { + "operationId": "api_v1_admin_makerspace_modules_uninstall_create", + "description": "Clears the capability key only. Rows, uploads and history are retained and reinstalling restores every surface. Destroying the data is a separate, irreversible step (`purge_module_data`) that is deliberately CLI-only.", + "summary": "Uninstall a module, keeping its data", "parameters": [ { "in": "path", @@ -31017,19 +30766,31 @@ "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer" - }, - "description": "Page number." } ], "tags": [ - "Admin bookings" + "Platform" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleAction" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ModuleAction" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ModuleAction" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -31037,62 +30798,24 @@ ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BookingMachineTypePage" - } - } - }, - "description": "" + "description": "Keys uninstalled." }, "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Bookings or machines module disabled." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Authentication required." + "description": "Core module, or required by an installed module." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." + "description": "Active superadmin access is required." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Not found." + "description": "Makerspace not found, archived, not active, or hidden from superadmins by its own manager." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/event-categories/": { + "/api/v1/admin/makerspace/{makerspace_id}/notification-destinations": { "get": { - "operationId": "api_v1_admin_makerspaces_event_categories_retrieve", + "operationId": "api_v1_admin_makerspace_notification_destinations_list", + "summary": "List or create chat notification destinations", "parameters": [ { "in": "path", @@ -31101,17 +30824,10 @@ "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer" - } } ], "tags": [ - "Admin events" + "Makerspaces" ], "security": [ { @@ -31123,97 +30839,29 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CategoryList" + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationDestination" + } } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Invalid field values, disabled events module or disabled feature." - }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not found, not visible to this actor, or the surface is not enabled here." } } }, "post": { - "operationId": "api_v1_admin_makerspaces_event_categories_create", + "operationId": "api_v1_admin_makerspace_notification_destinations_create", + "summary": "List or create chat notification destinations", "parameters": [ { "in": "path", @@ -31225,23 +30873,23 @@ } ], "tags": [ - "Admin events" + "Makerspaces" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CategoryInput" + "$ref": "#/components/schemas/NotificationDestinationWrite" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/CategoryInput" + "$ref": "#/components/schemas/NotificationDestinationWrite" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/CategoryInput" + "$ref": "#/components/schemas/NotificationDestinationWrite" } } }, @@ -31257,100 +30905,239 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Category" + "$ref": "#/components/schemas/NotificationDestination" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } + "description": "Invalid destination." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/notification-destinations/{destination_id}": { + "put": { + "operationId": "api_v1_admin_makerspace_notification_destinations_update", + "summary": "Update or delete a chat notification destination", + "parameters": [ + { + "in": "path", + "name": "destination_id", + "schema": { + "type": "integer" }, - "description": "Invalid field values, disabled events module or disabled feature." + "required": true }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Makerspaces" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationDestinationWrite" } }, - "description": "" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/NotificationDestinationWrite" } }, - "description": "" + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/NotificationDestinationWrite" + } + } }, - "404": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/NotificationDestination" } } }, "description": "" }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } + "400": { + "description": "Invalid destination." + } + } + }, + "delete": { + "operationId": "api_v1_admin_makerspace_notification_destinations_destroy", + "summary": "Update or delete a chat notification destination", + "parameters": [ + { + "in": "path", + "name": "destination_id", + "schema": { + "type": "integer" }, - "description": "" + "required": true }, - "423": { + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Makerspaces" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "204": { + "description": "Destination removed." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/notification-recipient-rules": { + "get": { + "operationId": "api_v1_admin_makerspace_notification_recipient_rules_retrieve", + "summary": "Read or replace notification recipients", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Makerspaces" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/RecipientRulesResponse" } } }, "description": "" }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + } + } + }, + "put": { + "operationId": "api_v1_admin_makerspace_notification_recipient_rules_update", + "summary": "Read or replace notification recipients", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Makerspaces" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecipientRulesPut" } }, - "description": "" + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/RecipientRulesPut" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/RecipientRulesPut" + } + } }, - "503": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/RecipientRulesResponse" } } }, "description": "" + }, + "400": { + "description": "Invalid recipient rule." + }, + "403": { + "description": "Recipient-rule permission required." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/event-collaborations/": { + "/api/v1/admin/makerspace/{makerspace_id}/notification-recipients": { "get": { - "operationId": "api_v1_admin_makerspaces_event_collaborations_list", - "summary": "List collaboration invitations for a makerspace", + "operationId": "api_v1_admin_makerspace_notification_recipients_list", + "description": "Space-manager control over which of the makerspace's managers receive the staff\nlifecycle emails. Toggling a manager off clears `receives_notifications` without\ntouching their access/role.", + "summary": "List or toggle staff email notification recipients", "parameters": [ { "in": "path", @@ -31362,7 +31149,7 @@ } ], "tags": [ - "Admin events" + "Makerspaces" ], "security": [ { @@ -31376,50 +31163,28 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/EventCollaborationInbox" + "$ref": "#/components/schemas/NotificationRecipient" } } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid collaboration request." + "401": { + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Event management access denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Event collaboration not found." + "description": "Not found, not visible to this actor, or the surface is not enabled here." } } - } - }, - "/api/v1/admin/makerspaces/{makerspace_id}/event-series/": { - "get": { - "operationId": "api_v1_admin_makerspaces_event_series_retrieve", - "summary": "List recurring event series", + }, + "patch": { + "operationId": "api_v1_admin_makerspace_notification_recipients_partial_update", + "description": "Space-manager control over which of the makerspace's managers receive the staff\nlifecycle emails. Toggling a manager off clears `receives_notifications` without\ntouching their access/role.", + "summary": "List or toggle staff email notification recipients", "parameters": [ { "in": "path", @@ -31431,8 +31196,27 @@ } ], "tags": [ - "Admin event series" + "Makerspaces" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedNotificationRecipientsPatch" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedNotificationRecipientsPatch" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedNotificationRecipientsPatch" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -31443,77 +31227,77 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventSeriesListResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationRecipient" + } } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid recurring event series." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Authentication required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Event management access denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } + "description": "Not found, not visible to this actor, or the surface is not enabled here." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/notification-rules": { + "get": { + "operationId": "api_v1_admin_makerspace_notification_rules_retrieve", + "summary": "List or update makerspace notification rules", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" }, - "description": "Event series not found." - }, - "409": { + "required": true + } + ], + "tags": [ + "Makerspaces" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/NotificationRulesResponse" } } }, - "description": "Series state conflict." + "description": "" }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Rate limit exceeded." + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." } } }, - "post": { - "operationId": "api_v1_admin_makerspaces_event_series_create", - "summary": "Create a recurring event series", + "patch": { + "operationId": "api_v1_admin_makerspace_notification_rules_partial_update", + "summary": "List or update makerspace notification rules", "parameters": [ { "in": "path", @@ -31525,27 +31309,26 @@ } ], "tags": [ - "Admin event series" + "Makerspaces" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventSeriesWrite" + "$ref": "#/components/schemas/PatchedNotificationRulesPatch" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/EventSeriesWrite" + "$ref": "#/components/schemas/PatchedNotificationRulesPatch" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/EventSeriesWrite" + "$ref": "#/components/schemas/PatchedNotificationRulesPatch" } } - }, - "required": true + } }, "security": [ { @@ -31553,37 +31336,66 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventSeriesMutationResponse" + "$ref": "#/components/schemas/NotificationRulesResponse" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } + "description": "Invalid notification rule or preference change." + }, + "404": { + "description": "Makerspace not found." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/payment-documents": { + "get": { + "operationId": "api_v1_admin_makerspace_payment_documents_retrieve", + "summary": "List scoped payment documents", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" }, - "description": "Invalid recurring event series." + "required": true }, - "401": { + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + } + ], + "tags": [ + "Payments" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DocumentPage" } } }, - "description": "Authentication required." + "description": "" }, - "403": { + "400": { "content": { "application/json": { "schema": { @@ -31591,9 +31403,9 @@ } } }, - "description": "Event management access denied." + "description": "" }, - "404": { + "401": { "content": { "application/json": { "schema": { @@ -31601,9 +31413,9 @@ } } }, - "description": "Event series not found." + "description": "" }, - "409": { + "403": { "content": { "application/json": { "schema": { @@ -31611,9 +31423,9 @@ } } }, - "description": "Series state conflict." + "description": "" }, - "429": { + "404": { "content": { "application/json": { "schema": { @@ -31621,15 +31433,24 @@ } } }, - "description": "Rate limit exceeded." + "description": "" } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/event-series-collaborations/": { + "/api/v1/admin/makerspace/{makerspace_id}/payment-documents/{document_id}/download-url": { "get": { - "operationId": "api_v1_admin_makerspaces_event_series_collaborations_list", + "operationId": "api_v1_admin_makerspace_payment_documents_download_url_retrieve", + "summary": "Get a user-bound five-minute PDF download URL", "parameters": [ + { + "in": "path", + "name": "document_id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -31640,7 +31461,7 @@ } ], "tags": [ - "Admin event series" + "Payments" ], "security": [ { @@ -31652,10 +31473,7 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SeriesCollaborationInbox" - } + "$ref": "#/components/schemas/DownloadGrant" } } }, @@ -31669,7 +31487,7 @@ } } }, - "description": "Invalid collaboration request." + "description": "" }, "401": { "content": { @@ -31679,7 +31497,7 @@ } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { @@ -31689,7 +31507,7 @@ } } }, - "description": "Event management access denied." + "description": "" }, "404": { "content": { @@ -31699,35 +31517,15 @@ } } }, - "description": "Series collaboration not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Collaboration state conflict." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Rate limit exceeded." + "description": "" } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/events/": { + "/api/v1/admin/makerspace/{makerspace_id}/payment-settings": { "get": { - "operationId": "api_v1_admin_makerspaces_events_retrieve", - "summary": "List events in a makerspace", + "operationId": "api_v1_admin_makerspace_payment_settings_retrieve", + "summary": "Retrieve makerspace payment settings", "parameters": [ { "in": "path", @@ -31736,47 +31534,10 @@ "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "ordering", - "schema": { - "type": "string" - }, - "description": "Allowlisted sort field; prefix with '-' for descending." - }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer" - }, - "description": "Page number, 24 rows per page." - }, - { - "in": "query", - "name": "q", - "schema": { - "type": "string" - }, - "description": "Search within the authorized list." - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "cancelled", - "completed", - "draft", - "published" - ] - } } ], "tags": [ - "Admin events" + "Admin payment settings" ], "security": [ { @@ -31788,27 +31549,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventListWithDefaults" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/MakerspacePaymentSettings" } } }, @@ -31818,7 +31559,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaymentSettingsError" } } }, @@ -31828,7 +31569,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaymentSettingsError" } } }, @@ -31836,9 +31577,9 @@ } } }, - "post": { - "operationId": "api_v1_admin_makerspaces_events_create", - "summary": "Create a draft event", + "patch": { + "operationId": "api_v1_admin_makerspace_payment_settings_partial_update", + "summary": "Update makerspace payment settings", "parameters": [ { "in": "path", @@ -31850,27 +31591,26 @@ } ], "tags": [ - "Admin events" + "Admin payment settings" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventWrite" + "$ref": "#/components/schemas/PatchedMakerspacePaymentSettings" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/EventWrite" + "$ref": "#/components/schemas/PatchedMakerspacePaymentSettings" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/EventWrite" + "$ref": "#/components/schemas/PatchedMakerspacePaymentSettings" } } - }, - "required": true + } }, "security": [ { @@ -31878,11 +31618,11 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventAdmin" + "$ref": "#/components/schemas/MakerspacePaymentSettings" } } }, @@ -31897,23 +31637,13 @@ } } }, - "description": "Invalid event details." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Field errors, including a credential change blocked by a live session." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaymentSettingsError" } } }, @@ -31923,7 +31653,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaymentSettingsError" } } }, @@ -31932,10 +31662,10 @@ } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/evidence-retention": { - "get": { - "operationId": "api_v1_admin_makerspaces_evidence_retention_retrieve", - "summary": "Get a makerspace evidence retention and abandoned-upload policy", + "/api/v1/admin/makerspace/{makerspace_id}/payment-settings/connect/onboard": { + "post": { + "operationId": "api_v1_admin_makerspace_payment_settings_connect_onboard_create", + "summary": "Start Stripe Connect onboarding", "parameters": [ { "in": "path", @@ -31947,7 +31677,7 @@ } ], "tags": [ - "Evidence retention" + "Admin payment settings" ], "security": [ { @@ -31959,171 +31689,70 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EvidenceRetentionPolicy" + "$ref": "#/components/schemas/StripeConnectOnboarding" } } }, "description": "" }, - "401": { - "description": "Authentication is required." - }, "403": { - "description": "Active manage-makerspace permission is required." - }, - "404": { - "description": "Makerspace was not found in the actor's scope." - }, - "503": { - "description": "Deployment recovery is active." - } - } - }, - "patch": { - "operationId": "api_v1_admin_makerspaces_evidence_retention_partial_update", - "summary": "Partially update the evidence retention and abandoned-upload policy", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Evidence retention" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedEvidenceRetentionPatch" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedEvidenceRetentionPatch" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedEvidenceRetentionPatch" - } - } - } - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EvidenceRetentionPolicy" + "$ref": "#/components/schemas/PaymentSettingsError" } } }, "description": "" }, - "400": { - "description": "Retention days are invalid." - }, - "401": { - "description": "Authentication is required." - }, - "403": { - "description": "Active manage-makerspace permission is required." - }, "404": { - "description": "Makerspace was not found in the actor's scope." - }, - "503": { - "description": "Deployment recovery is active." - } - } - } - }, - "/api/v1/admin/makerspaces/{makerspace_id}/evidence-retention/preview": { - "post": { - "operationId": "api_v1_admin_makerspaces_evidence_retention_preview_create", - "summary": "Preview evidence object expiry and abandoned-upload cleanup", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Evidence retention" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EvidenceRetentionPreviewRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/EvidenceRetentionPreviewRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/EvidenceRetentionPreviewRequest" - } - } - } - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EvidenceRetentionPreviewResponse" + "$ref": "#/components/schemas/PaymentSettingsError" } } }, "description": "" }, - "400": { - "description": "Preview limit is invalid." - }, - "401": { - "description": "Authentication is required." - }, - "403": { - "description": "Active manage-makerspace permission is required." - }, - "404": { - "description": "Makerspace was not found in the actor's scope." - }, "503": { - "description": "Deployment recovery is active." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentSettingsError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/invitation-requests": { + "/api/v1/admin/makerspace/{makerspace_id}/payments": { "get": { - "operationId": "api_v1_admin_makerspaces_invitation_requests_list", - "description": "Requires manage_members in this makerspace. Staff role administration and membership plan definitions require manage_makerspace.", - "summary": "List invitation requests (optionally filtered by ?status=)", + "operationId": "api_v1_admin_makerspace_payments_retrieve", + "description": "Requires view_finance. With for_recording=true, requires record_payment and returns only pending and offline-settled charges for counter receipt work.", + "summary": "List makerspace payments for reconciliation", "parameters": [ + { + "in": "query", + "name": "due_state", + "schema": { + "type": "string", + "enum": [ + "due_today", + "overdue", + "upcoming" + ] + } + }, + { + "in": "query", + "name": "for_recording", + "schema": { + "type": "boolean" + } + }, { "in": "path", "name": "makerspace_id", @@ -32131,10 +31760,57 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "ordering", + "schema": { + "type": "string" + }, + "description": "Allowlisted sort field; prefix with '-' for descending." + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + }, + "description": "Page number, 24 rows per page." + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" + ] + } } ], "tags": [ - "Admin memberships" + "Payments" ], "security": [ { @@ -32146,10 +31822,7 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/InvitationRequest" - } + "$ref": "#/components/schemas/PaymentListResponse" } } }, @@ -32159,7 +31832,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaymentFilterError" } } }, @@ -32173,7 +31846,7 @@ } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { @@ -32183,7 +31856,7 @@ } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { @@ -32193,7 +31866,7 @@ } } }, - "description": "" + "description": "Payment or makerspace not found." }, "409": { "content": { @@ -32203,22 +31876,17 @@ } } }, - "description": "" + "description": "Payment is already terminal." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/machine-materials": { - "get": { - "operationId": "api_v1_admin_makerspaces_machine_materials_list", + "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/amend-settlement": { + "post": { + "operationId": "api_v1_admin_makerspace_payments_amend_settlement_create", + "description": "Requires record_payment or the existing scoped payment-subject authority. Appends a correction; the original receipt remains immutable.", + "summary": "Correct how a settled payment was received", "parameters": [ - { - "in": "query", - "name": "machine_type_id", - "schema": { - "type": "integer" - } - }, { "in": "path", "name": "makerspace_id", @@ -32228,18 +31896,37 @@ "required": true }, { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", + "in": "path", + "name": "payment_id", "schema": { "type": "integer" - } + }, + "required": true } ], "tags": [ - "api" + "Payments" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentOffline" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PaymentOffline" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PaymentOffline" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -32250,23 +31937,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedMaterialList" - } - } - }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaymentReconciliation" } } }, "description": "" }, - "403": { + "400": { "content": { "application/json": { "schema": { @@ -32274,9 +31951,9 @@ } } }, - "description": "" + "description": "Invalid payment request." }, - "404": { + "401": { "content": { "application/json": { "schema": { @@ -32284,9 +31961,9 @@ } } }, - "description": "" + "description": "Authentication required." }, - "423": { + "403": { "content": { "application/json": { "schema": { @@ -32294,9 +31971,9 @@ } } }, - "description": "" + "description": "Permission denied." }, - "429": { + "404": { "content": { "application/json": { "schema": { @@ -32304,9 +31981,9 @@ } } }, - "description": "" + "description": "Payment or makerspace not found." }, - "503": { + "409": { "content": { "application/json": { "schema": { @@ -32314,23 +31991,16 @@ } } }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - }, - "description": "" + "description": "Payment is already terminal." } } - }, + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/mark-offline": { "post": { - "operationId": "api_v1_admin_makerspaces_machine_materials_create", + "operationId": "api_v1_admin_makerspace_payments_mark_offline_create", + "description": "Requires record_payment or the existing scoped payment-subject authority. Appends an audited manual settlement receipt.", + "summary": "Mark a payment paid offline", "parameters": [ { "in": "path", @@ -32339,26 +32009,34 @@ "type": "integer" }, "required": true + }, + { + "in": "path", + "name": "payment_id", + "schema": { + "type": "integer" + }, + "required": true } ], "tags": [ - "api" + "Payments" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Material" + "$ref": "#/components/schemas/PaymentOffline" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/Material" + "$ref": "#/components/schemas/PaymentOffline" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/Material" + "$ref": "#/components/schemas/PaymentOffline" } } }, @@ -32370,27 +32048,17 @@ } ], "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Material" - } - } - }, - "description": "" - }, - "401": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaymentReconciliation" } } }, "description": "" }, - "403": { + "400": { "content": { "application/json": { "schema": { @@ -32398,9 +32066,9 @@ } } }, - "description": "" + "description": "Invalid payment request." }, - "404": { + "401": { "content": { "application/json": { "schema": { @@ -32408,9 +32076,9 @@ } } }, - "description": "" + "description": "Authentication required." }, - "423": { + "403": { "content": { "application/json": { "schema": { @@ -32418,9 +32086,9 @@ } } }, - "description": "" + "description": "Permission denied." }, - "429": { + "404": { "content": { "application/json": { "schema": { @@ -32428,9 +32096,9 @@ } } }, - "description": "" + "description": "Payment or makerspace not found." }, - "503": { + "409": { "content": { "application/json": { "schema": { @@ -32438,29 +32106,20 @@ } } }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - }, - "description": "" + "description": "Payment is already terminal." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/machine-materials/{id}": { - "get": { - "operationId": "api_v1_admin_makerspaces_machine_materials_list_2", + "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/refund": { + "post": { + "operationId": "api_v1_admin_makerspace_payments_refund_create", + "description": "Sends money back through the provider that took it and records a Refund line on the payment. Only `paid_online` payments qualify; the sum of pending and succeeded refunds can never exceed the payment amount. The Payment row itself is immutable -- refund state lives on the Refund rows returned in `refunds`.", + "summary": "Refund a payment settled online, fully or partially", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -32468,25 +32127,36 @@ }, { "in": "path", - "name": "makerspace_id", + "name": "payment_id", "schema": { "type": "integer" }, "required": true - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } } ], "tags": [ - "api" + "Payments" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentRefundRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PaymentRefundRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PaymentRefundRequest" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -32497,13 +32167,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedMaterialList" + "$ref": "#/components/schemas/PaymentReconciliation" } } }, "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { @@ -32511,7 +32181,7 @@ } } }, - "description": "" + "description": "Refund not allowed or over the balance." }, "403": { "content": { @@ -32521,7 +32191,7 @@ } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { @@ -32531,29 +32201,9 @@ } } }, - "description": "" - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Payment or makerspace not found." }, - "503": { + "502": { "content": { "application/json": { "schema": { @@ -32561,27 +32211,20 @@ } } }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - }, - "description": "" + "description": "The provider rejected the refund." } } - }, - "patch": { - "operationId": "api_v1_admin_makerspaces_machine_materials_partial_update", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/refund-offline": { + "post": { + "operationId": "api_v1_admin_makerspace_payments_refund_offline_create", + "description": "Requires active staff and record_payment. Records money already returned on a paid_offline charge, fully or partially, with method, optional financial reference and refunded_at. The receipt is append-only and audited; all pending/succeeded refunds share one balance. No provider call or payments module is required. The Payment remains unchanged.", + "summary": "Record an offline refund or release a cash loan deposit", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -32589,7 +32232,7 @@ }, { "in": "path", - "name": "makerspace_id", + "name": "payment_id", "schema": { "type": "integer" }, @@ -32597,26 +32240,27 @@ } ], "tags": [ - "api" + "Payments" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedMaterial" + "$ref": "#/components/schemas/PaymentOfflineRefund" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedMaterial" + "$ref": "#/components/schemas/PaymentOfflineRefund" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedMaterial" + "$ref": "#/components/schemas/PaymentOfflineRefund" } } - } + }, + "required": true }, "security": [ { @@ -32628,43 +32272,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Material" - } - } - }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaymentReconciliation" } } }, "description": "" }, - "404": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ManualRefundError" } } }, - "description": "" + "description": "Invalid receipt, status or refund balance." }, - "423": { + "401": { "content": { "application/json": { "schema": { @@ -32672,9 +32296,9 @@ } } }, - "description": "" + "description": "Authentication required." }, - "429": { + "403": { "content": { "application/json": { "schema": { @@ -32682,9 +32306,9 @@ } } }, - "description": "" + "description": "Record payment and active status required." }, - "503": { + "404": { "content": { "application/json": { "schema": { @@ -32692,27 +32316,20 @@ } } }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - }, - "description": "" + "description": "Payment not found in the permitted makerspace." } } - }, - "delete": { - "operationId": "api_v1_admin_makerspaces_machine_materials_destroy", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/refunds/{refund_id}/amend": { + "post": { + "operationId": "api_v1_admin_makerspace_payments_refunds_amend_create", + "description": "Requires record_payment. Replaces method, reference and returned date on the effective receipt; amount/currency and the original payment never change. No second refund event or transfer occurs.", + "summary": "Append a corrected manual refund receipt", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -32720,7 +32337,15 @@ }, { "in": "path", - "name": "makerspace_id", + "name": "payment_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "refund_id", "schema": { "type": "integer" }, @@ -32728,48 +32353,55 @@ } ], "tags": [ - "api" + "Payments" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentRefundAmend" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PaymentRefundAmend" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PaymentRefundAmend" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "204": { - "description": "No response body" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "403": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaymentReconciliation" } } }, "description": "" }, - "404": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ManualRefundError" } } }, "description": "" }, - "423": { + "401": { "content": { "application/json": { "schema": { @@ -32779,7 +32411,7 @@ }, "description": "" }, - "429": { + "403": { "content": { "application/json": { "schema": { @@ -32789,7 +32421,7 @@ }, "description": "" }, - "503": { + "404": { "content": { "application/json": { "schema": { @@ -32798,24 +32430,15 @@ } }, "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - }, - "description": "" } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/machine-service/consumable-pools": { - "get": { - "operationId": "api_v1_admin_makerspaces_machine_service_consumable_pools_list", + "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/release-deposit": { + "post": { + "operationId": "api_v1_admin_makerspace_payments_release_deposit_create", + "description": "Requires record_payment. Only a paid_offline loan deposit on a returned or closed_with_issue loan is eligible. Records actual money returned; never offsets fees or calls the rail.", + "summary": "Record a deposit returned after a loan closes", "parameters": [ { "in": "path", @@ -32824,38 +32447,10 @@ "type": "integer" }, "required": true - } - ], - "tags": [ - "Admin machine service" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PrinterPool" - } - } - } - }, - "description": "" - } - } - }, - "post": { - "operationId": "api_v1_admin_makerspaces_machine_service_consumable_pools_create", - "parameters": [ + }, { "in": "path", - "name": "makerspace_id", + "name": "payment_id", "schema": { "type": "integer" }, @@ -32863,23 +32458,23 @@ } ], "tags": [ - "Admin machine service" + "Payments" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PrinterPoolCreate" + "$ref": "#/components/schemas/PaymentOfflineRefund" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PrinterPoolCreate" + "$ref": "#/components/schemas/PaymentOfflineRefund" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PrinterPoolCreate" + "$ref": "#/components/schemas/PaymentOfflineRefund" } } }, @@ -32891,11 +32486,51 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PrinterPool" + "$ref": "#/components/schemas/PaymentReconciliation" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManualRefundError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -32904,39 +32539,11 @@ } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/machine-service/requests": { - "get": { - "operationId": "api_v1_admin_makerspaces_machine_service_requests_list", - "summary": "List machine service requests", + "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/waive": { + "post": { + "operationId": "api_v1_admin_makerspace_payments_waive_create", + "summary": "Waive a payment", "parameters": [ - { - "in": "query", - "name": "bucket", - "schema": { - "type": "integer" - } - }, - { - "in": "query", - "name": "machine", - "schema": { - "type": "integer" - } - }, - { - "in": "query", - "name": "machine_type", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "machine_type_id", - "schema": { - "type": "integer" - } - }, { "in": "path", "name": "makerspace_id", @@ -32946,22 +32553,16 @@ "required": true }, { - "in": "query", - "name": "queue", + "in": "path", + "name": "payment_id", "schema": { "type": "integer" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string" - } + }, + "required": true } ], "tags": [ - "Admin machine service" + "Payments" ], "security": [ { @@ -32973,10 +32574,7 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineServiceRequest" - } + "$ref": "#/components/schemas/PaymentReconciliation" } } }, @@ -32990,16 +32588,37 @@ } } }, - "description": "Invalid service request input." + "description": "Invalid payment request." }, "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, "description": "Authentication required." }, "403": { - "description": "Machine management permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "Service request was not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Payment or makerspace not found." }, "409": { "content": { @@ -33009,13 +32628,16 @@ } } }, - "description": "Service workflow conflict." + "description": "Payment is already terminal." } } - }, + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/payments/bulk/mark-offline": { "post": { - "operationId": "api_v1_admin_makerspaces_machine_service_requests_create", - "summary": "Submit a machine service request for a member", + "operationId": "api_v1_admin_makerspace_payments_bulk_mark_offline_create", + "description": "Requires record_payment or the existing scoped payment-subject authority for every charge in the batch.", + "summary": "Mark payments paid offline in one transaction", "parameters": [ { "in": "path", @@ -33027,23 +32649,23 @@ } ], "tags": [ - "Admin machine service" + "Payments" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceSubmit" + "$ref": "#/components/schemas/PaymentBulkOffline" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MachineServiceSubmit" + "$ref": "#/components/schemas/PaymentBulkOffline" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MachineServiceSubmit" + "$ref": "#/components/schemas/PaymentBulkOffline" } } }, @@ -33055,11 +32677,14 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceRequest" + "type": "array", + "items": { + "$ref": "#/components/schemas/PaymentReconciliation" + } } } }, @@ -33073,16 +32698,37 @@ } } }, - "description": "Invalid service request input." + "description": "Invalid payment request." }, "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, "description": "Authentication required." }, "403": { - "description": "Machine management permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "Service request was not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Payment or makerspace not found." }, "409": { "content": { @@ -33092,29 +32738,16 @@ } } }, - "description": "Service workflow conflict." + "description": "Payment is already terminal." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/machine-service/typed-manual-usage": { - "get": { - "operationId": "api_v1_admin_makerspaces_machine_service_typed_manual_usage_list", + "/api/v1/admin/makerspace/{makerspace_id}/payments/bulk/waive": { + "post": { + "operationId": "api_v1_admin_makerspace_payments_bulk_waive_create", + "summary": "Waive payments in one transaction", "parameters": [ - { - "in": "query", - "name": "machine_type", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "machine_type_id", - "schema": { - "type": "integer" - } - }, { "in": "path", "name": "makerspace_id", @@ -33125,8 +32758,28 @@ } ], "tags": [ - "Admin machine service" + "Payments" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentBulkAction" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PaymentBulkAction" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PaymentBulkAction" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -33139,18 +32792,82 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/TypedManualUsageResponse" + "$ref": "#/components/schemas/PaymentReconciliation" } } } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid payment request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Payment or makerspace not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Payment is already terminal." } } - }, - "post": { - "operationId": "api_v1_admin_makerspaces_machine_service_typed_manual_usage_create", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/pending-requests": { + "get": { + "operationId": "api_v1_admin_makerspace_pending_requests_list", + "summary": "List pending borrow requests", "parameters": [ + { + "name": "id", + "in": "query", + "required": false, + "description": "Select one result within the authorized list.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + } + }, { "in": "path", "name": "makerspace_id", @@ -33158,53 +32875,168 @@ "type": "integer" }, "required": true + }, + { + "name": "ordering", + "required": false, + "in": "query", + "description": "Which field to use when ordering the results.", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "required": false, + "in": "query", + "description": "A search term (requested-for, requester name/email).", + "schema": { + "type": "string" + } } ], "tags": [ - "Admin machine service" + "Admin requests" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedManualUsage" + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedAdminRequestList" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/TypedManualUsage" + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/TypedManualUsage" + "description": "Invalid request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } - } + }, + "description": "Authentication required." }, - "required": true - }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/presence-sessions/current": { + "get": { + "operationId": "api_v1_admin_makerspace_presence_sessions_current_list", + "description": "Requires manage_members in this makerspace.", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin makerspaces" + ], "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedManualUsageResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/PresenceRoster" + } } } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid input." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Membership permission required." + }, + "404": { + "description": "Makerspace not found." + }, + "429": { + "description": "Rate limit exceeded." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/machine-types/{type_id}/intake": { + "/api/v1/admin/makerspace/{makerspace_id}/printer-jobs": { "get": { - "operationId": "api_v1_admin_makerspaces_machine_types_intake_retrieve", + "operationId": "api_v1_admin_makerspace_printer_jobs_list", "parameters": [ { "in": "path", @@ -33215,16 +33047,17 @@ "required": true }, { - "in": "path", - "name": "type_id", + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", "schema": { "type": "integer" - }, - "required": true + } } ], "tags": [ - "api" + "Devices" ], "security": [ { @@ -33236,87 +33069,86 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Intake" + "$ref": "#/components/schemas/PaginatedJobList" } } }, "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Invalid payload or adapter module disabled." }, - "403": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Authentication/signature/replay refused." }, - "404": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Authority or device revoked." }, - "423": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Target outside scope or absent." }, - "429": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Conflicting command/report." }, - "503": { + "410": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Delivery lease expired." }, - "400": { + "423": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Tenant frozen for migration." } } }, - "put": { - "operationId": "api_v1_admin_makerspaces_machine_types_intake_update", + "post": { + "operationId": "api_v1_admin_makerspace_printer_jobs_create", "parameters": [ { "in": "path", @@ -33325,34 +33157,26 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "type_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "api" + "Devices" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Intake" + "$ref": "#/components/schemas/JobCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/Intake" + "$ref": "#/components/schemas/JobCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/Intake" + "$ref": "#/components/schemas/JobCreate" } } }, @@ -33364,98 +33188,97 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Intake" + "$ref": "#/components/schemas/Job" } } }, "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Invalid payload or adapter module disabled." }, - "403": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Authentication/signature/replay refused." }, - "404": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Authority or device revoked." }, - "423": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Target outside scope or absent." }, - "429": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Conflicting command/report." }, - "503": { + "410": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Delivery lease expired." }, - "400": { + "423": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Tenant frozen for migration." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/machines/{machine_id}/maintenance/logs/": { - "get": { - "operationId": "api_v1_admin_makerspaces_machines_maintenance_logs_retrieve", - "summary": "List immutable machine maintenance logs", + "/api/v1/admin/makerspace/{makerspace_id}/problem-reports/{id}/resolve": { + "post": { + "operationId": "api_v1_admin_makerspace_problem_reports_resolve_create", + "summary": "Resolve a public problem report", "parameters": [ { "in": "path", - "name": "machine_id", + "name": "id", "schema": { "type": "integer" }, @@ -33471,7 +33294,7 @@ } ], "tags": [ - "Admin maintenance" + "Analytics" ], "security": [ { @@ -33483,34 +33306,24 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceLogList" + "type": "object", + "additionalProperties": {} } } }, "description": "" - }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." } } - }, + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/problem-reports/{id}/triage": { "post": { - "operationId": "api_v1_admin_makerspaces_machines_maintenance_logs_create", - "description": "When schedule_id is supplied, complete that schedule and set its next due date according to its recurrence anchor. Completion anchors add interval_days to the makerspace-local date of performed_at; calendar anchors advance the previous due date by whole intervals past completion. performed_at defaults to now; backdated entries use their recorded completion date.", - "summary": "Record completed machine maintenance", + "operationId": "api_v1_admin_makerspace_problem_reports_triage_create", + "summary": "Triage a public problem report", "parameters": [ { "in": "path", - "name": "machine_id", + "name": "id", "schema": { "type": "integer" }, @@ -33526,23 +33339,23 @@ } ], "tags": [ - "Admin maintenance" + "Analytics" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceLogWrite" + "$ref": "#/components/schemas/ProblemReportTriage" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MaintenanceLogWrite" + "$ref": "#/components/schemas/ProblemReportTriage" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MaintenanceLogWrite" + "$ref": "#/components/schemas/ProblemReportTriage" } } }, @@ -33554,50 +33367,25 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceLog" + "$ref": "#/components/schemas/ProblemReportTriageResponse" } } }, "description": "" - }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "409": { - "description": "Conflicts with the current state." - }, - "423": { - "description": "Makerspace writes are frozen for tenant migration." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/machines/{machine_id}/maintenance/schedules/": { + "/api/v1/admin/makerspace/{makerspace_id}/profile": { "get": { - "operationId": "api_v1_admin_makerspaces_machines_maintenance_schedules_retrieve", - "summary": "List machine maintenance schedules", + "operationId": "api_v1_admin_makerspace_profile_retrieve", + "description": "Core configuration: usable even with every optional module switched off.", + "summary": "Read location and opening hours", "parameters": [ - { - "in": "path", - "name": "machine_id", - "schema": { - "type": "integer" - }, - "required": true - }, { "in": "path", "name": "makerspace_id", @@ -33608,7 +33396,7 @@ } ], "tags": [ - "Admin maintenance" + "Admin makerspaces" ], "security": [ { @@ -33620,15 +33408,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceScheduleList" + "$ref": "#/components/schemas/MakerspaceProfile" } } }, "description": "" }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, "401": { "description": "Not signed in." }, @@ -33640,18 +33425,11 @@ } } }, - "post": { - "operationId": "api_v1_admin_makerspaces_machines_maintenance_schedules_create", - "summary": "Create a machine maintenance schedule", + "patch": { + "operationId": "api_v1_admin_makerspace_profile_partial_update", + "description": "Requires manage_makerspace. Empty hours mean always open. Dated exceptions replace weekly hours. Currency updates the existing payment default, never currencies on existing charges.", + "summary": "Update location and opening hours", "parameters": [ - { - "in": "path", - "name": "machine_id", - "schema": { - "type": "integer" - }, - "required": true - }, { "in": "path", "name": "makerspace_id", @@ -33662,27 +33440,26 @@ } ], "tags": [ - "Admin maintenance" + "Admin makerspaces" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceScheduleWrite" + "$ref": "#/components/schemas/PatchedMakerspaceProfile" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MaintenanceScheduleWrite" + "$ref": "#/components/schemas/PatchedMakerspaceProfile" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MaintenanceScheduleWrite" + "$ref": "#/components/schemas/PatchedMakerspaceProfile" } } - }, - "required": true + } }, "security": [ { @@ -33690,40 +33467,62 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceSchedule" + "$ref": "#/components/schemas/MakerspaceProfile" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "409": { - "description": "Conflicts with the current state." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileError" + } + } + }, + "description": "" }, "423": { - "description": "Makerspace writes are frozen for tenant migration." - } - } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileGateError" + } + } + }, + "description": "Tenant writes are frozen for migration." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileGateError" + } + } + }, + "description": "The source gate cannot prove writes are safe." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + } + } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/maintenance/feed": { - "get": { - "operationId": "api_v1_admin_makerspaces_maintenance_feed_retrieve", + "/api/v1/admin/makerspace/{makerspace_id}/provision-subdomain": { + "post": { + "operationId": "api_v1_admin_makerspace_provision_subdomain_create", + "summary": "Provision a platform subdomain for a makerspace", "parameters": [ { "in": "path", @@ -33735,8 +33534,28 @@ } ], "tags": [ - "api" + "Admin makerspaces" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProvisionSubdomainRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ProvisionSubdomainRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ProvisionSubdomainRequest" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -33747,88 +33566,182 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FeedState" + "$ref": "#/components/schemas/Makerspace" } } }, "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ProvisionSubdomainValidationError" } } }, - "description": "" + "description": "Invalid or unavailable platform subdomain label." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/HostingError" } } }, - "description": "" + "description": "Active superadmin access is required." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/HostingError" } } }, - "description": "" + "description": "Makerspace not found." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/qr-print-batches": { + "get": { + "operationId": "api_v1_admin_makerspace_qr_print_batches_list", + "summary": "List QR print batches", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true }, - "423": { + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + } + ], + "tags": [ + "QR print batches" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaginatedQrPrintBatchList" } } }, "description": "" - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + } + } + }, + "post": { + "operationId": "api_v1_admin_makerspace_qr_print_batches_create", + "summary": "Create QR print batch", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "QR print batches" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QrPrintBatchCreate" } }, - "description": "" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/QrPrintBatchCreate" } }, - "description": "" + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/QrPrintBatchCreate" + } + } }, - "400": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/components/schemas/QrPrintBatch" } } }, "description": "" } } - }, - "post": { - "operationId": "api_v1_admin_makerspaces_maintenance_feed_create", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/reports/{report_key}/export": { + "get": { + "operationId": "api_v1_admin_makerspace_reports_export_retrieve", + "description": "Subject reports require live resource management plus tenant VIEW_AUDIT. For bookable_space use booking-resource-utilization, booking-reservations or booking-answers; bookings.analytics / bookings.exports must be enabled. Resource PDF requires tenant MANAGE_BOOKINGS, bookings.utilization_pdf and complete start/end dates. Dates are tenant-local, end inclusive; reservation detail includes overlapping windows. No credentials are exported. Event PDFs require full event management and an ended event. Subjects cannot be scheduled or aggregated. Active-loan and return detail rows include count=1 for chart summaries of returned rows. Event, booking and audit detail exports retain their explicit column allowlists and use table specs. FabLab health rows include makerspace_id in tenant previews.", + "summary": "Export report", "parameters": [ + { + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "format", + "schema": { + "type": "string", + "enum": [ + "csv", + "pdf", + "xlsx" + ] + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month", + "week" + ] + } + }, { "in": "path", "name": "makerspace_id", @@ -33836,10 +33749,167 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "months", + "schema": { + "type": "integer" + }, + "description": "Retention anniversary in months (1-120); default 3, 6 and 12." + }, + { + "in": "path", + "name": "report_key", + "schema": { + "type": "string", + "enum": [ + "accounting-journal", + "active-loans", + "asset-usage", + "audit-activity", + "audit-activity-hours", + "audit-top-actors", + "audit-trail", + "booking-answers", + "booking-lead-time", + "booking-occupancy", + "booking-reservations", + "booking-resource-utilization", + "booking-resource-utilization-pdf", + "booking-top-members", + "booking-utilization", + "cash-book", + "certification-coverage", + "communications-health", + "community-engagement", + "consumable-pools", + "consumable-usage", + "damaged-lost", + "damaged-missing", + "device-health", + "event-attendance", + "event-attendance-pdf", + "event-checkin-times", + "event-feedback", + "event-feedback-scores", + "event-funnel", + "event-registrations", + "event-revenue", + "event-signups", + "evidence-compliance", + "fablab-health", + "import-quality", + "inventory-control", + "inventory-utilisation", + "inventory-watch-demand", + "loan-throughput", + "longest-loans", + "machine-downtime", + "machine-errors", + "machine-service", + "machine-usage", + "machine-usage-heatmap", + "machine-usage-members", + "maintenance-activity", + "member-activity", + "member-retention", + "membership-growth", + "membership-revenue", + "mobile-auth", + "mobile-devices", + "module-operational-health", + "most-lent", + "organization-membership", + "overdue-loans", + "payment-cash-book", + "payment-reconciliation", + "payments-ageing", + "payments-ledger", + "platform-services", + "platform-usage", + "presence-activity", + "presence-activity-hours", + "presence-top-visitors", + "printer-jobs", + "printer-service", + "problem-reports", + "procurement-performance", + "qr-scan-actors", + "qr-scans", + "recently-added", + "request-funnel", + "returns", + "revenue-over-time", + "service-queue-times", + "stock-health", + "stock-transfers", + "stocktake-variance", + "summary", + "taken-items", + "top-borrowers", + "warranty-expiry" + ] + }, + "required": true + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_id", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "subject_kind", + "schema": { + "type": "string" + }, + "description": "Typed tenant-local subject (event or bookable_space), paired with subject_id. Booking resources require manage_reservations plus tenant VIEW_AUDIT; resource PDF requires tenant MANAGE_BOOKINGS and both dates. Subject filters cannot be scheduled or aggregated." + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" + ] + } } ], "tags": [ - "api" + "Reports" ], "security": [ { @@ -33847,91 +33917,85 @@ } ], "responses": { - "200": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FeedIssued" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Render rate limit exceeded." }, - "401": { + "200": { "content": { - "application/json": { + "application/pdf": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "string", + "format": "binary" } - } - }, - "description": "" - }, - "403": { - "content": { - "application/json": { + }, + "text/csv": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "string" } - } - }, - "description": "" - }, - "404": { - "content": { - "application/json": { + }, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "string", + "format": "binary" } } }, "description": "" }, - "423": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Invalid report request." }, - "429": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Authentication required." }, - "503": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Permission denied." }, - "400": { + "404": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Makerspace or report not found." } } - }, - "delete": { - "operationId": "api_v1_admin_makerspaces_maintenance_feed_destroy", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/reports/catalog": { + "get": { + "operationId": "api_v1_admin_makerspace_reports_catalog_retrieve", + "summary": "List makerspace report catalog", "parameters": [ { "in": "path", @@ -33943,7 +34007,7 @@ } ], "tags": [ - "api" + "Reports" ], "security": [ { @@ -33951,87 +34015,75 @@ } ], "responses": { - "204": { - "description": "No response body" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "404": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportCatalog" } } }, "description": "" }, - "423": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Invalid report request." }, - "429": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Authentication required." }, - "503": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Permission denied." }, - "400": { + "404": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/maintenance/feed/{raw_token}.ics": { + "/api/v1/admin/makerspace/{makerspace_id}/request-history": { "get": { - "operationId": "api_v1_admin_makerspaces_maintenance_feed_.ics_retrieve", + "operationId": "api_v1_admin_makerspace_request_history_list", + "summary": "List terminal request history (returned / rejected / closed with issue)", "parameters": [ + { + "name": "id", + "in": "query", + "required": false, + "description": "Select one result within the authorized list.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + } + }, { "in": "path", "name": "makerspace_id", @@ -34041,33 +34093,53 @@ "required": true }, { - "in": "path", - "name": "raw_token", + "name": "ordering", + "required": false, + "in": "query", + "description": "Which field to use when ordering the results.", "schema": { "type": "string" - }, - "required": true + } + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "required": false, + "in": "query", + "description": "A search term (requested-for, requester name/email).", + "schema": { + "type": "string" + } } ], "tags": [ - "api" + "Admin requests" + ], + "security": [ + { + "jwtAuth": [] + } ], "responses": { "200": { "content": { - "text/calendar": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/PaginatedAdminRequestList" } } }, "description": "" }, - "304": { - "description": "No response body" - }, - "401": { + "400": { "content": { "application/json": { "schema": { @@ -34075,9 +34147,9 @@ } } }, - "description": "" + "description": "Invalid request." }, - "403": { + "401": { "content": { "application/json": { "schema": { @@ -34085,9 +34157,9 @@ } } }, - "description": "" + "description": "Authentication required." }, - "404": { + "403": { "content": { "application/json": { "schema": { @@ -34095,29 +34167,9 @@ } } }, - "description": "" - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Permission denied." }, - "503": { + "404": { "content": { "application/json": { "schema": { @@ -34125,26 +34177,14 @@ } } }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - }, - "description": "" + "description": "Not found." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/member-card-template": { + "/api/v1/admin/makerspace/{makerspace_id}/return-policy": { "get": { - "operationId": "api_v1_admin_makerspaces_member_card_template_retrieve", - "summary": "Read the card layout template", + "operationId": "api_v1_admin_makerspace_return_policy_retrieve", "parameters": [ { "in": "path", @@ -34156,7 +34196,7 @@ } ], "tags": [ - "Admin memberships" + "Admin makerspaces" ], "security": [ { @@ -34168,7 +34208,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberCardTemplate" + "$ref": "#/components/schemas/ReturnPolicy" } } }, @@ -34182,7 +34222,7 @@ } } }, - "description": "Invalid card input or membership module disabled." + "description": "" }, "401": { "content": { @@ -34192,7 +34232,7 @@ } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { @@ -34202,7 +34242,7 @@ } } }, - "description": "Missing member-card action or inactive account." + "description": "" }, "404": { "content": { @@ -34212,13 +34252,32 @@ } } }, - "description": "Not found in the permitted makerspace." + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } }, - "put": { - "operationId": "api_v1_admin_makerspaces_member_card_template_update", - "summary": "Replace the card layout template", + "patch": { + "operationId": "api_v1_admin_makerspace_return_policy_partial_update", "parameters": [ { "in": "path", @@ -34230,23 +34289,23 @@ } ], "tags": [ - "Admin memberships" + "Admin makerspaces" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberCardTemplate" + "$ref": "#/components/schemas/PatchedReturnPolicy" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MemberCardTemplate" + "$ref": "#/components/schemas/PatchedReturnPolicy" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MemberCardTemplate" + "$ref": "#/components/schemas/PatchedReturnPolicy" } } } @@ -34261,7 +34320,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberCardTemplate" + "$ref": "#/components/schemas/ReturnPolicy" } } }, @@ -34275,7 +34334,7 @@ } } }, - "description": "Invalid card input or membership module disabled." + "description": "" }, "401": { "content": { @@ -34285,7 +34344,7 @@ } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { @@ -34295,7 +34354,7 @@ } } }, - "description": "Missing member-card action or inactive account." + "description": "" }, "404": { "content": { @@ -34305,15 +34364,35 @@ } } }, - "description": "Not found in the permitted makerspace." + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/member-cards": { + "/api/v1/admin/makerspace/{makerspace_id}/setup-state": { "get": { - "operationId": "api_v1_admin_makerspaces_member_cards_list", - "summary": "List member cards", + "operationId": "api_v1_admin_makerspace_setup_state_retrieve", + "summary": "Read derived makerspace setup progress", "parameters": [ { "in": "path", @@ -34322,18 +34401,55 @@ "type": "integer" }, "required": true + } + ], + "tags": [ + "Makerspaces" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetupState" + } + } + }, + "description": "" + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/space-pack": { + "get": { + "operationId": "api_v1_admin_makerspace_space_pack_retrieve", + "summary": "Export configuration-only space pack", + "parameters": [ { - "in": "query", - "name": "status", + "in": "path", + "name": "makerspace_id", "schema": { - "type": "string" + "type": "integer" }, - "description": "active | revoked" + "required": true } ], "tags": [ - "Admin memberships" + "Admin makerspaces" ], "security": [ { @@ -34345,10 +34461,7 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberCard" - } + "$ref": "#/components/schemas/SpacePack" } } }, @@ -34358,49 +34471,78 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PackError" } } }, - "description": "Invalid card input or membership module disabled." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PackError" } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PackError" } } }, - "description": "Missing member-card action or inactive account." + "description": "" }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PackError" } } }, - "description": "Not found in the permitted makerspace." + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackError" + } + } + }, + "description": "" } } - } - }, - "/api/v1/admin/makerspaces/{makerspace_id}/member-cards.pdf": { + }, "post": { - "operationId": "api_v1_admin_makerspaces_member_cards.pdf_create", - "summary": "Print a sheet of cards", + "operationId": "api_v1_admin_makerspace_space_pack_create", + "description": "Dry-run defaults true. Apply needs base_checksum from preview. Modules remain superadmin-only. Unrelated entries are retained.", + "summary": "Preview or atomically apply a space pack", "parameters": [ { "in": "path", @@ -34412,26 +34554,27 @@ } ], "tags": [ - "Admin memberships" + "Admin makerspaces" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberCardPrint" + "$ref": "#/components/schemas/PackImport" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MemberCardPrint" + "$ref": "#/components/schemas/PackImport" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MemberCardPrint" + "$ref": "#/components/schemas/PackImport" } } - } + }, + "required": true }, "security": [ { @@ -34441,10 +34584,9 @@ "responses": { "200": { "content": { - "application/pdf": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/PackPreview" } } }, @@ -34454,49 +34596,79 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PackError" } } }, - "description": "Invalid card input or membership module disabled." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PackError" } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PackError" } } }, - "description": "Missing member-card action or inactive account." + "description": "" }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PackError" } } }, - "description": "Not found in the permitted makerspace." + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/member-cards/{membership_id}/issue": { - "post": { - "operationId": "api_v1_admin_makerspaces_member_cards_issue_create", - "summary": "Issue a card to a membership", + "/api/v1/admin/makerspace/{makerspace_id}/stock-transfers": { + "get": { + "operationId": "api_v1_admin_makerspace_stock_transfers_list", + "summary": "List stock transfers", "parameters": [ { "in": "path", @@ -34507,47 +34679,29 @@ "required": true }, { - "in": "path", - "name": "membership_id", + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", "schema": { "type": "integer" - }, - "required": true + } } ], "tags": [ - "Admin memberships" + "Stock transfers" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MemberCardIssue" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/MemberCardIssue" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/MemberCardIssue" - } - } - } - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberCard" + "$ref": "#/components/schemas/PaginatedStockTransferList" } } }, @@ -34557,49 +34711,26 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/GenericObject" } } }, - "description": "Invalid card input or membership module disabled." + "description": "Invalid request." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Authentication required." + "description": "Authentication credentials were not provided." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Missing member-card action or inactive account." + "description": "Permission denied." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Not found in the permitted makerspace." + "description": "Not found." } } - } - }, - "/api/v1/admin/makerspaces/{makerspace_id}/member-cards/resolve": { + }, "post": { - "operationId": "api_v1_admin_makerspaces_member_cards_resolve_create", - "summary": "Resolve a scanned member card to minimal identity", + "operationId": "api_v1_admin_makerspace_stock_transfers_create", + "summary": "Create stock transfer", "parameters": [ { "in": "path", @@ -34611,23 +34742,23 @@ } ], "tags": [ - "Admin memberships" + "Stock transfers" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberCardResolve" + "$ref": "#/components/schemas/StockTransferCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MemberCardResolve" + "$ref": "#/components/schemas/StockTransferCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MemberCardResolve" + "$ref": "#/components/schemas/StockTransferCreate" } } }, @@ -34639,11 +34770,11 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberCardResolveResult" + "$ref": "#/components/schemas/StockTransfer" } } }, @@ -34653,49 +34784,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/GenericObject" } } }, - "description": "Invalid card input or membership module disabled." + "description": "Invalid request." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Authentication required." + "description": "Authentication credentials were not provided." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Missing member-card action or inactive account." + "description": "Permission denied." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Not found in the permitted makerspace." + "description": "Not found." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/member-claim-codes": { + "/api/v1/admin/makerspace/{makerspace_id}/stocktake-policy": { "get": { - "operationId": "api_v1_admin_makerspaces_member_claim_codes_list", - "summary": "List active physically handed member claim codes", + "operationId": "api_v1_admin_makerspace_stocktake_policy_retrieve", "parameters": [ { "in": "path", @@ -34707,7 +34816,7 @@ } ], "tags": [ - "Admin memberships" + "api" ], "security": [ { @@ -34719,16 +34828,13 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberClaimCode" - } + "$ref": "#/components/schemas/Policy" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -34738,7 +34844,7 @@ }, "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -34748,7 +34854,7 @@ }, "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -34758,7 +34864,7 @@ }, "description": "" }, - "404": { + "423": { "content": { "application/json": { "schema": { @@ -34768,7 +34874,17 @@ }, "description": "" }, - "409": { + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { "content": { "application/json": { "schema": { @@ -34777,12 +34893,22 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + }, + "description": "" } } }, - "post": { - "operationId": "api_v1_admin_makerspaces_member_claim_codes_create", - "summary": "Issue a claim code to an eligible walk-in member", + "put": { + "operationId": "api_v1_admin_makerspace_stocktake_policy_update", "parameters": [ { "in": "path", @@ -34794,27 +34920,26 @@ } ], "tags": [ - "Admin memberships" + "api" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberClaimCodeIssueRequest" + "$ref": "#/components/schemas/Policy" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MemberClaimCodeIssueRequest" + "$ref": "#/components/schemas/Policy" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MemberClaimCodeIssueRequest" + "$ref": "#/components/schemas/Policy" } } - }, - "required": true + } }, "security": [ { @@ -34822,17 +34947,17 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberClaimCodeIssueResponse" + "$ref": "#/components/schemas/Policy" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -34842,7 +34967,7 @@ }, "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -34852,7 +34977,7 @@ }, "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -34862,7 +34987,7 @@ }, "description": "" }, - "404": { + "423": { "content": { "application/json": { "schema": { @@ -34872,7 +34997,7 @@ }, "description": "" }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -34882,7 +35007,7 @@ }, "description": "" }, - "429": { + "503": { "content": { "application/json": { "schema": { @@ -34890,35 +35015,46 @@ } } }, - "description": "Issue rate limit exceeded." + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + }, + "description": "" } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/member-claim-codes/{claim_id}/revoke": { - "post": { - "operationId": "api_v1_admin_makerspaces_member_claim_codes_revoke_create", - "summary": "Revoke a member claim code and its bound session", + "/api/v1/admin/makerspace/{makerspace_id}/stocktake-schedules": { + "get": { + "operationId": "api_v1_admin_makerspace_stocktake_schedules_list", "parameters": [ { "in": "path", - "name": "claim_id", + "name": "makerspace_id", "schema": { "type": "integer" }, "required": true }, { - "in": "path", - "name": "makerspace_id", + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", "schema": { "type": "integer" - }, - "required": true + } } ], "tags": [ - "Admin memberships" + "api" ], "security": [ { @@ -34930,13 +35066,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberClaimCode" + "$ref": "#/components/schemas/PaginatedScheduleList" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -34946,7 +35082,7 @@ }, "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -34956,7 +35092,7 @@ }, "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -34966,7 +35102,7 @@ }, "description": "" }, - "404": { + "423": { "content": { "application/json": { "schema": { @@ -34976,7 +35112,17 @@ }, "description": "" }, - "409": { + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { "content": { "application/json": { "schema": { @@ -34985,14 +35131,22 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + }, + "description": "" } } - } - }, - "/api/v1/admin/makerspaces/{makerspace_id}/membership-admission": { - "get": { - "operationId": "api_v1_admin_makerspaces_membership_admission_retrieve", - "description": "Requires manage_makerspace; settings are separate from manage_members.", + }, + "post": { + "operationId": "api_v1_admin_makerspace_stocktake_schedules_create", "parameters": [ { "in": "path", @@ -35004,25 +35158,45 @@ } ], "tags": [ - "Admin memberships" + "api" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Schedule" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Schedule" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Schedule" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdmissionSettings" + "$ref": "#/components/schemas/Schedule" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -35032,7 +35206,7 @@ }, "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -35042,7 +35216,7 @@ }, "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -35052,7 +35226,7 @@ }, "description": "" }, - "404": { + "423": { "content": { "application/json": { "schema": { @@ -35062,7 +35236,7 @@ }, "description": "" }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -35072,7 +35246,7 @@ }, "description": "" }, - "423": { + "503": { "content": { "application/json": { "schema": { @@ -35082,22 +35256,32 @@ }, "description": "" }, - "503": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true } } }, "description": "" } } - }, - "patch": { - "operationId": "api_v1_admin_makerspaces_membership_admission_partial_update", - "description": "Requires manage_makerspace. Paid admissions require charge tracking, not an online gateway.", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/stocktake-schedules/{id}": { + "get": { + "operationId": "api_v1_admin_makerspace_stocktake_schedules_retrieve", "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -35108,27 +35292,8 @@ } ], "tags": [ - "Admin memberships" + "api" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedAdmissionSettings" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedAdmissionSettings" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedAdmissionSettings" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -35139,13 +35304,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdmissionSettings" + "$ref": "#/components/schemas/Schedule" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -35155,7 +35320,7 @@ }, "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -35165,7 +35330,7 @@ }, "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -35175,7 +35340,7 @@ }, "description": "" }, - "404": { + "423": { "content": { "application/json": { "schema": { @@ -35185,7 +35350,7 @@ }, "description": "" }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -35195,7 +35360,7 @@ }, "description": "" }, - "423": { + "503": { "content": { "application/json": { "schema": { @@ -35205,25 +35370,30 @@ }, "description": "" }, - "503": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true } } }, "description": "" } } - } - }, - "/api/v1/admin/makerspaces/{makerspace_id}/membership-plans": { - "get": { - "operationId": "api_v1_admin_makerspaces_membership_plans_list", - "description": "Requires manage_members in this makerspace. Staff role administration and membership plan definitions require manage_makerspace.", - "summary": "List membership plans", + }, + "patch": { + "operationId": "api_v1_admin_makerspace_stocktake_schedules_partial_update", "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -35234,8 +35404,27 @@ } ], "tags": [ - "Admin memberships" + "api" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedSchedule" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedSchedule" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedSchedule" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -35246,16 +35435,13 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MembershipPlan" - } + "$ref": "#/components/schemas/Schedule" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -35265,7 +35451,7 @@ }, "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -35275,7 +35461,7 @@ }, "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -35285,7 +35471,7 @@ }, "description": "" }, - "404": { + "423": { "content": { "application/json": { "schema": { @@ -35295,7 +35481,17 @@ }, "description": "" }, - "409": { + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { "content": { "application/json": { "schema": { @@ -35304,14 +35500,31 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + }, + "description": "" } } }, - "post": { - "operationId": "api_v1_admin_makerspaces_membership_plans_create", - "description": "Requires manage_makerspace in this makerspace; manage_members alone cannot change plan definitions or prices.", - "summary": "Create a membership plan", + "delete": { + "operationId": "api_v1_admin_makerspace_stocktake_schedules_destroy", "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -35322,45 +35535,28 @@ } ], "tags": [ - "Admin memberships" + "api" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MembershipPlan" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/MembershipPlan" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/MembershipPlan" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "204": { + "description": "No response body" + }, + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipPlan" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "400": { + "403": { "content": { "application/json": { "schema": { @@ -35370,7 +35566,7 @@ }, "description": "" }, - "401": { + "404": { "content": { "application/json": { "schema": { @@ -35380,7 +35576,7 @@ }, "description": "" }, - "403": { + "423": { "content": { "application/json": { "schema": { @@ -35390,7 +35586,7 @@ }, "description": "" }, - "404": { + "429": { "content": { "application/json": { "schema": { @@ -35400,7 +35596,7 @@ }, "description": "" }, - "409": { + "503": { "content": { "application/json": { "schema": { @@ -35409,14 +35605,25 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + }, + "description": "" } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/memberships": { + "/api/v1/admin/makerspace/{makerspace_id}/stocktakes": { "get": { - "operationId": "api_v1_admin_makerspaces_memberships_list", - "description": "Requires manage_members in this makerspace. Staff creation and role administration require manage_makerspace.", + "operationId": "api_v1_admin_makerspace_stocktakes_list", + "summary": "List stocktakes", "parameters": [ { "in": "path", @@ -35425,10 +35632,34 @@ "type": "integer" }, "required": true + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "applied", + "approved", + "cancelled", + "completed", + "counting", + "draft" + ] + } } ], "tags": [ - "Admin memberships" + "Stocktake" ], "security": [ { @@ -35440,10 +35671,7 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MembershipList" - } + "$ref": "#/components/schemas/PaginatedStocktakeList" } } }, @@ -35453,56 +35681,26 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/GenericObject" } } }, - "description": "" + "description": "Invalid request or stocktake state." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Authentication credentials were not provided." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Permission denied." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not found." } } }, "post": { - "operationId": "api_v1_admin_makerspaces_memberships_create", + "operationId": "api_v1_admin_makerspace_stocktakes_create", + "summary": "Create stocktake", "parameters": [ { "in": "path", @@ -35514,27 +35712,26 @@ } ], "tags": [ - "Admin memberships" + "Stocktake" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipCreate" + "$ref": "#/components/schemas/StocktakeCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MembershipCreate" + "$ref": "#/components/schemas/StocktakeCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MembershipCreate" + "$ref": "#/components/schemas/StocktakeCreate" } } - }, - "required": true + } }, "security": [ { @@ -35546,7 +35743,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipList" + "$ref": "#/components/schemas/Stocktake" } } }, @@ -35556,58 +35753,91 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/GenericObject" } } }, - "description": "" + "description": "Invalid request or stocktake state." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Authentication credentials were not provided." }, "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/subdomain-request": { + "get": { + "operationId": "api_v1_admin_makerspace_subdomain_request_list", + "summary": "List platform subdomain requests for a makerspace", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + } + ], + "tags": [ + "Admin hosting" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaginatedSubdomainRequestList" } } }, "description": "" }, - "404": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/SubdomainRequestError" } } }, "description": "" }, - "409": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/SubdomainRequestError" } } }, "description": "" } } - } - }, - "/api/v1/admin/makerspaces/{makerspace_id}/memberships/{membership_id}/role": { - "patch": { - "operationId": "api_v1_admin_makerspaces_memberships_role_partial_update", + }, + "post": { + "operationId": "api_v1_admin_makerspace_subdomain_request_create", + "summary": "Request a platform subdomain for a makerspace", "parameters": [ { "in": "path", @@ -35616,37 +35846,30 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "membership_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Admin memberships" + "Admin hosting" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedMembershipRoleAssign" + "$ref": "#/components/schemas/SubdomainRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedMembershipRoleAssign" + "$ref": "#/components/schemas/SubdomainRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedMembershipRoleAssign" + "$ref": "#/components/schemas/SubdomainRequest" } } - } + }, + "required": true }, "security": [ { @@ -35654,11 +35877,11 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipList" + "$ref": "#/components/schemas/SubdomainRequest" } } }, @@ -35668,17 +35891,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/SubdomainRequestError" } } }, @@ -35688,7 +35901,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/SubdomainRequestError" } } }, @@ -35698,17 +35911,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/SubdomainRequestError" } } }, @@ -35717,10 +35920,10 @@ } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/report-schedules": { + "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/disclosure-approvals": { "get": { - "operationId": "api_v1_admin_makerspaces_report_schedules_list", - "summary": "List report schedules", + "operationId": "api_v1_admin_makerspace_tenant_migration_disclosure_approvals_list", + "summary": "List disclosure approvals", "parameters": [ { "in": "path", @@ -35729,19 +35932,10 @@ "type": "integer" }, "required": true - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } } ], "tags": [ - "Reports" + "Tenant migration" ], "security": [ { @@ -35753,27 +35947,20 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedReportScheduleList" + "type": "array", + "items": { + "$ref": "#/components/schemas/ClosureApproval" + } } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Invalid schedule or module disabled." - }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/TypedError" } } }, @@ -35783,47 +35970,37 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Permission denied." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "Makerspace or schedule not found." + "description": "Superadmin access required." }, - "423": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "Tenant writes are frozen." + "description": "Throttle limit exceeded." }, - "503": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "Tenant write gate unavailable." + "description": "Unexpected migration failure." } } }, "post": { - "operationId": "api_v1_admin_makerspaces_report_schedules_create", - "summary": "Create report schedule", + "operationId": "api_v1_admin_makerspace_tenant_migration_disclosure_approvals_create", + "summary": "Approve each identity in one exact disclosure closure", "parameters": [ { "in": "path", @@ -35835,23 +36012,23 @@ } ], "tags": [ - "Reports" + "Tenant migration" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportSchedule" + "$ref": "#/components/schemas/ClosureApprovalCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ReportSchedule" + "$ref": "#/components/schemas/ClosureApprovalCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ReportSchedule" + "$ref": "#/components/schemas/ClosureApprovalCreate" } } }, @@ -35867,7 +36044,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportSchedule" + "$ref": "#/components/schemas/ClosureApproval" } } }, @@ -35877,70 +36054,79 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/FieldValidationError" } } }, - "description": "Invalid schedule or module disabled." + "description": "Field-keyed validation errors." }, - "401": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "Authentication required." + "description": "State conflict." }, - "403": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "Permission denied." + "description": "Authentication required." }, - "404": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "Makerspace or schedule not found." + "description": "Superadmin access required." }, - "423": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "Tenant writes are frozen." + "description": "Throttle limit exceeded." }, - "503": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "Tenant write gate unavailable." + "description": "Unexpected migration failure." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/roles": { - "get": { - "operationId": "api_v1_admin_makerspaces_roles_list", - "description": "Requires manage_makerspace for all roles, or manage_members for action-less community roles only.", + "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/disclosure-approvals/{approval_id}/revoke": { + "post": { + "operationId": "api_v1_admin_makerspace_tenant_migration_disclosure_approvals_revoke_create", + "summary": "Revoke a disclosure approval", "parameters": [ + { + "in": "path", + "name": "approval_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -35951,7 +36137,7 @@ } ], "tags": [ - "Admin roles" + "Tenant migration" ], "security": [ { @@ -35963,69 +36149,69 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Role" - } + "$ref": "#/components/schemas/ClosureApproval" } } }, "description": "" }, - "400": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Not found." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "404": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "409": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } - }, - "post": { - "operationId": "api_v1_admin_makerspaces_roles_create", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/disclosure-closure": { + "get": { + "operationId": "api_v1_admin_makerspace_tenant_migration_disclosure_closure_retrieve", + "summary": "Compute the pending PORTABLE disclosure closure", "parameters": [ { "in": "path", @@ -36037,49 +36223,19 @@ } ], "tags": [ - "Admin roles" + "Tenant migration" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RoleCreate" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/RoleCreate" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/RoleCreate" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Role" - } - } - }, - "description": "" - }, - "400": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PendingClosure" } } }, @@ -36089,48 +36245,49 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "404": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "409": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/roles/{role_id}": { + "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/exports": { "get": { - "operationId": "api_v1_admin_makerspaces_roles_retrieve", + "operationId": "api_v1_admin_makerspace_tenant_migration_exports_list", + "summary": "List PORTABLE migration export jobs", "parameters": [ { "in": "path", @@ -36139,18 +36296,10 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "role_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Admin roles" + "Tenant migration" ], "security": [ { @@ -36162,17 +36311,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Role" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "array", + "items": { + "$ref": "#/components/schemas/MigrationExportJob" + } } } }, @@ -36182,46 +36324,47 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "404": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "409": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } }, - "patch": { - "operationId": "api_v1_admin_makerspaces_roles_partial_update", + "post": { + "operationId": "api_v1_admin_makerspace_tenant_migration_exports_create", + "summary": "Create a target-recipient-encrypted PORTABLE export", "parameters": [ { "in": "path", @@ -36230,37 +36373,30 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "role_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Admin roles" + "Tenant migration" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedRoleWrite" + "$ref": "#/components/schemas/MigrationExportCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedRoleWrite" + "$ref": "#/components/schemas/MigrationExportCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedRoleWrite" + "$ref": "#/components/schemas/MigrationExportCreate" } } - } + }, + "required": true }, "security": [ { @@ -36268,11 +36404,11 @@ } ], "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Role" + "$ref": "#/components/schemas/MigrationExportJob" } } }, @@ -36282,68 +36418,85 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/FieldValidationError" } } }, - "description": "" + "description": "Field-keyed validation errors." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "State conflict." + }, + "503": { + "description": "Migration worker unavailable." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "404": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "409": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } - }, - "delete": { - "operationId": "api_v1_admin_makerspaces_roles_destroy", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/exports/{job_id}": { + "get": { + "operationId": "api_v1_admin_makerspace_tenant_migration_exports_retrieve", + "summary": "Read a PORTABLE migration export job", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "job_id", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "required": true }, { "in": "path", - "name": "role_id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -36351,7 +36504,7 @@ } ], "tags": [ - "Admin roles" + "Tenant migration" ], "security": [ { @@ -36359,78 +36512,86 @@ } ], "responses": { - "204": { - "description": "No response body" - }, - "400": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/MigrationExportJob" } } }, "description": "" }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Not found." + }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "404": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "409": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/roles/{role_id}/machine-scope": { - "get": { - "operationId": "api_v1_admin_makerspaces_roles_machine_scope_retrieve", - "description": "Which machines a role's MANAGE_MACHINES grant reaches.\n\nConsole parity: machine scoping fails closed, so without this surface a Space Manager\ncould create a machine-managing role and have no way to make it able to manage\nanything — the capability would exist only in `/control/` and the shell.", + "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/exports/{job_id}/download-url": { + "post": { + "operationId": "api_v1_admin_makerspace_tenant_migration_exports_download_url_create", + "summary": "Issue the one-use PORTABLE archive download URL", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "job_id", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "required": true }, { "in": "path", - "name": "role_id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -36438,7 +36599,7 @@ } ], "tags": [ - "Admin roles" + "Tenant migration" ], "security": [ { @@ -36450,7 +36611,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RoleMachineScope" + "$ref": "#/components/schemas/DataExportDownloadUrl" } } }, @@ -36460,69 +36621,82 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "State conflict." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Not found." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "404": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "409": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } - }, - "put": { - "operationId": "api_v1_admin_makerspaces_roles_machine_scope_update", - "description": "Which machines a role's MANAGE_MACHINES grant reaches.\n\nConsole parity: machine scoping fails closed, so without this surface a Space Manager\ncould create a machine-managing role and have no way to make it able to manage\nanything — the capability would exist only in `/control/` and the shell.", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/exports/{job_id}/quiesce": { + "post": { + "operationId": "api_v1_admin_makerspace_tenant_migration_exports_quiesce_create", + "summary": "Reassert a completed export's source gate lease", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "job_id", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "required": true }, { "in": "path", - "name": "role_id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -36530,28 +36704,8 @@ } ], "tags": [ - "Admin roles" + "Tenant migration" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RoleMachineScopeWrite" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/RoleMachineScopeWrite" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/RoleMachineScopeWrite" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -36562,68 +36716,79 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RoleMachineScope" + "$ref": "#/components/schemas/CutoverOutcome" } } }, "description": "" }, - "400": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "State conflict." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "404": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "409": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/roles/capabilities": { - "get": { - "operationId": "api_v1_admin_makerspaces_roles_capabilities_list", + "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/pairings/{pairing_id}/archive-source": { + "post": { + "operationId": "api_v1_admin_makerspace_tenant_migration_pairings_archive_source_create", + "summary": "Archive the quiesced source and issue its signed cutover receipt", "parameters": [ { "in": "path", @@ -36632,10 +36797,19 @@ "type": "integer" }, "required": true + }, + { + "in": "path", + "name": "pairing_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true } ], "tags": [ - "Admin roles" + "Tenant migration" ], "security": [ { @@ -36647,71 +36821,79 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Capability" - } + "$ref": "#/components/schemas/CutoverOutcome" } } }, "description": "" }, - "400": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "State conflict." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "404": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "409": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/saved-report-views": { - "get": { - "operationId": "api_v1_admin_makerspaces_saved_report_views_retrieve", + "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/pairings/{pairing_id}/recover": { + "post": { + "operationId": "api_v1_admin_makerspace_tenant_migration_pairings_recover_create", + "summary": "Recover an archived source with the target abort receipt", "parameters": [ { "in": "path", @@ -36722,16 +36904,38 @@ "required": true }, { - "in": "query", - "name": "page", + "in": "path", + "name": "pairing_id", "schema": { - "type": "integer" - } + "type": "string", + "format": "uuid" + }, + "required": true } ], "tags": [ - "Reports" + "Tenant migration" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CutoverReceiptRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/CutoverReceiptRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/CutoverReceiptRequest" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -36742,7 +36946,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SavedReportViewList" + "$ref": "#/components/schemas/CutoverOutcome" } } }, @@ -36752,66 +36956,79 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/FieldValidationError" } } }, - "description": "" + "description": "Field-keyed validation errors." }, - "401": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Not found." }, - "403": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "State conflict." }, - "404": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, - "423": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "503": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Unexpected migration failure." } } - }, - "post": { - "operationId": "api_v1_admin_makerspaces_saved_report_views_create", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/usage": { + "get": { + "operationId": "api_v1_admin_makerspace_usage_retrieve", + "summary": "Read own makerspace usage and effective limits", "parameters": [ { "in": "path", @@ -36823,118 +37040,94 @@ } ], "tags": [ - "Reports" + "Admin makerspaces" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SavedReportView" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/SavedReportView" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/SavedReportView" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SavedReportView" + "$ref": "#/components/schemas/MakerspaceUsage" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "" + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "" + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "" + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } + "description": "Not found, not visible to this actor, or the surface is not enabled here." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/verify-domain": { + "post": { + "operationId": "api_v1_admin_makerspace_verify_domain_create", + "summary": "Verify a makerspace custom domain", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" }, - "description": "" - }, - "423": { + "required": true + } + ], + "tags": [ + "Admin makerspaces" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/DomainVerificationResponse" } } }, "description": "" }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "" + "403": { + "description": "Missing makerspace management permission." + }, + "404": { + "description": "Makerspace not found." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/saved-report-views/{id}": { + "/api/v1/admin/makerspace/{makerspace_id}/warranties": { "get": { - "operationId": "api_v1_admin_makerspaces_saved_report_views_retrieve_2", + "operationId": "api_v1_admin_makerspace_warranties_list", + "summary": "List warranty coverage for a makerspace", "parameters": [ { - "in": "path", - "name": "id", + "in": "query", + "name": "expires_before", "schema": { - "type": "integer" + "type": "string", + "format": "date" }, - "required": true + "description": "Only include hosts with warranty expiry on or before this date." }, { "in": "path", @@ -36943,10 +37136,50 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "missing_docs", + "schema": { + "type": "boolean" + }, + "description": "Only include hosts with no warranty documents or no warranty record." + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + }, + { + "name": "page_size", + "required": false, + "in": "query", + "description": "Number of results to return per page.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "active", + "expired", + "expiring_soon", + "unknown" + ] + }, + "description": "Filter rows by computed warranty status." } ], "tags": [ - "Reports" + "Admin warranty" ], "security": [ { @@ -36958,17 +37191,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SavedReportView" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/PaginatedWarrantyReportRowList" } } }, @@ -36978,7 +37201,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -36988,7 +37211,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -36998,36 +37221,136 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" - }, - "423": { + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/whatsapp-templates": { + "get": { + "operationId": "api_v1_admin_makerspace_whatsapp_templates_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Messaging" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/WhatsAppBindingPage" } } }, "description": "" }, - "503": { + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + } + } + }, + "post": { + "operationId": "api_v1_admin_makerspace_whatsapp_templates_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Messaging" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WhatsAppBinding" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/WhatsAppBinding" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/WhatsAppBinding" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/WhatsAppBinding" } } }, "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "409": { + "description": "Conflicts with the current state." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." + }, + "503": { + "description": "A required dependency is unavailable." } } - }, + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/whatsapp-templates/{id}": { "patch": { - "operationId": "api_v1_admin_makerspaces_saved_report_views_partial_update", + "operationId": "api_v1_admin_makerspace_whatsapp_templates_partial_update", "parameters": [ { "in": "path", @@ -37047,23 +37370,23 @@ } ], "tags": [ - "Reports" + "Messaging" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedSavedReportView" + "$ref": "#/components/schemas/PatchedWhatsAppBinding" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedSavedReportView" + "$ref": "#/components/schemas/PatchedWhatsAppBinding" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedSavedReportView" + "$ref": "#/components/schemas/PatchedWhatsAppBinding" } } } @@ -37078,76 +37401,37 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SavedReportView" + "$ref": "#/components/schemas/WhatsAppBinding" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "" + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "" + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "" + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "" + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "409": { + "description": "Conflicts with the current state." }, "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "" + "description": "Makerspace writes are frozen for tenant migration." }, "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "" + "description": "A required dependency is unavailable." } } }, "delete": { - "operationId": "api_v1_admin_makerspaces_saved_report_views_destroy", + "operationId": "api_v1_admin_makerspace_whatsapp_templates_destroy", "parameters": [ { "in": "path", @@ -37167,7 +37451,7 @@ } ], "tags": [ - "Reports" + "Messaging" ], "security": [ { @@ -37178,90 +37462,28 @@ "204": { "description": "No response body" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "" - }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "" + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "" + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "" + "description": "Not found, not visible to this actor, or the surface is not enabled here." }, "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "" + "description": "Makerspace writes are frozen for tenant migration." }, "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "" + "description": "A required dependency is unavailable." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/saved-views": { + "/api/v1/admin/makerspace/{makerspace_id}/whatsapp-templates/catalog": { "get": { - "operationId": "api_v1_admin_makerspaces_saved_views_retrieve", + "operationId": "api_v1_admin_makerspace_whatsapp_templates_catalog_list", "parameters": [ - { - "in": "query", - "name": "entity", - "schema": { - "type": "string", - "enum": [ - "audit", - "bookings", - "events", - "inventory", - "members", - "payments", - "requests" - ] - }, - "required": true - }, { "in": "path", "name": "makerspace_id", @@ -37269,17 +37491,10 @@ "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer" - } } ], "tags": [ - "Staff lists" + "Messaging" ], "security": [ { @@ -37291,104 +37506,80 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SavedViewList" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "array", + "items": { + "$ref": "#/components/schemas/MessagingCatalogEntry" + } } } }, "description": "" }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + } + } + } + }, + "/api/v1/admin/makerspaces": { + "get": { + "operationId": "api_v1_admin_makerspaces_list", + "summary": "List makerspaces the actor has a staff surface in", + "tags": [ + "Admin makerspaces" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "array", + "items": { + "$ref": "#/components/schemas/Makerspace" + } } } }, "description": "" }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "403": { + "description": "Not an active staff account." } } }, "post": { - "operationId": "api_v1_admin_makerspaces_saved_views_create", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_admin_makerspaces_create", + "description": "Exactly one write-only organization_id (existing active owner organization) or organization_name (new owner organization) is required; both are refused on update. Owner assignment and makerspace creation are atomic. Optional install_profile_id applies a saved pack; an unknown profile returns 404 before owner assignment. creation_setup applies profile, hours, modules and first staff invitation in the same transaction; explicit setup overrides the pack. Invitation delivery follows commit and returns a private fallback link.", + "summary": "Create a makerspace (superadmin only)", "tags": [ - "Staff lists" + "Admin makerspaces" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SavedView" + "$ref": "#/components/schemas/Makerspace" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/SavedView" + "$ref": "#/components/schemas/Makerspace" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/SavedView" + "$ref": "#/components/schemas/Makerspace" } } }, @@ -37404,7 +37595,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SavedView" + "$ref": "#/components/schemas/Makerspace" } } }, @@ -37414,33 +37605,120 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": {}, + "properties": { + "theme_config": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "required": [ + "code", + "messages" + ], + "properties": { + "code": { + "type": "string", + "enum": [ + "theme_contrast" + ] + }, + "messages": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + } + } } } }, - "description": "" + "description": "Field validation errors. Contrast errors have theme_config: {code: 'theme_contrast', messages: string[]} with mode, failing pair, measured ratio and required ratio." }, "401": { + "description": "Not signed in." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "409": { + "description": "Conflicts with the current state." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." + }, + "503": { + "description": "A required dependency is unavailable." + }, + "403": { + "description": "Only a superadmin can create a makerspace." + } + } + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/booking-machine-types/": { + "get": { + "operationId": "api_v1_admin_makerspaces_booking_machine_types_retrieve", + "summary": "List machine type choices for booking spaces", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + }, + "description": "Page number." + } + ], + "tags": [ + "Admin bookings" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/BookingMachineTypePage" } } }, "description": "" }, - "403": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": {} } } }, - "description": "" + "description": "Bookings or machines module disabled." }, - "404": { + "401": { "content": { "application/json": { "schema": { @@ -37448,9 +37726,9 @@ } } }, - "description": "" + "description": "Authentication required." }, - "423": { + "403": { "content": { "application/json": { "schema": { @@ -37458,9 +37736,9 @@ } } }, - "description": "" + "description": "Permission denied." }, - "503": { + "404": { "content": { "application/json": { "schema": { @@ -37468,34 +37746,33 @@ } } }, - "description": "" + "description": "Not found." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/saved-views/{id}": { + "/api/v1/admin/makerspaces/{makerspace_id}/event-categories/": { "get": { - "operationId": "api_v1_admin_makerspaces_saved_views_retrieve_2", + "operationId": "api_v1_admin_makerspaces_event_categories_retrieve", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, "required": true }, { - "in": "path", - "name": "makerspace_id", + "in": "query", + "name": "page", "schema": { "type": "integer" - }, - "required": true + } } ], "tags": [ - "Staff lists" + "Admin events" ], "security": [ { @@ -37507,7 +37784,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SavedView" + "$ref": "#/components/schemas/CategoryList" } } }, @@ -37517,11 +37794,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": {} } } }, - "description": "" + "description": "Invalid field values, disabled events module or disabled feature." }, "401": { "content": { @@ -37553,6 +37831,16 @@ }, "description": "" }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, "423": { "content": { "application/json": { @@ -37563,6 +37851,16 @@ }, "description": "" }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, "503": { "content": { "application/json": { @@ -37575,17 +37873,9 @@ } } }, - "patch": { - "operationId": "api_v1_admin_makerspaces_saved_views_partial_update", + "post": { + "operationId": "api_v1_admin_makerspaces_event_categories_create", "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - }, { "in": "path", "name": "makerspace_id", @@ -37596,26 +37886,27 @@ } ], "tags": [ - "Staff lists" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedSavedView" + "$ref": "#/components/schemas/CategoryInput" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedSavedView" + "$ref": "#/components/schemas/CategoryInput" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedSavedView" + "$ref": "#/components/schemas/CategoryInput" } } - } + }, + "required": true }, "security": [ { @@ -37623,11 +37914,11 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SavedView" + "$ref": "#/components/schemas/Category" } } }, @@ -37637,11 +37928,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": {} } } }, - "description": "" + "description": "Invalid field values, disabled events module or disabled feature." }, "401": { "content": { @@ -37673,6 +37965,16 @@ }, "description": "" }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, "423": { "content": { "application/json": { @@ -37683,6 +37985,16 @@ }, "description": "" }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, "503": { "content": { "application/json": { @@ -37694,18 +38006,13 @@ "description": "" } } - }, - "delete": { - "operationId": "api_v1_admin_makerspaces_saved_views_destroy", + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/event-collaborations/": { + "get": { + "operationId": "api_v1_admin_makerspaces_event_collaborations_list", + "summary": "List collaboration invitations for a makerspace", "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - }, { "in": "path", "name": "makerspace_id", @@ -37716,7 +38023,7 @@ } ], "tags": [ - "Staff lists" + "Admin events" ], "security": [ { @@ -37724,20 +38031,20 @@ } ], "responses": { - "204": { - "description": "No response body" - }, - "400": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "array", + "items": { + "$ref": "#/components/schemas/EventCollaborationInbox" + } } } }, "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { @@ -37745,7 +38052,7 @@ } } }, - "description": "" + "description": "Invalid collaboration request." }, "403": { "content": { @@ -37755,7 +38062,7 @@ } } }, - "description": "" + "description": "Event management access denied." }, "404": { "content": { @@ -37765,35 +38072,15 @@ } } }, - "description": "" - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Event collaboration not found." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/spaces/": { + "/api/v1/admin/makerspaces/{makerspace_id}/event-series/": { "get": { - "operationId": "api_v1_admin_makerspaces_spaces_retrieve", - "summary": "List bookable spaces in a makerspace", + "operationId": "api_v1_admin_makerspaces_event_series_retrieve", + "summary": "List recurring event series", "parameters": [ { "in": "path", @@ -37802,34 +38089,10 @@ "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "ordering", - "schema": { - "type": "string" - }, - "description": "Allowlisted sort field; prefix with '-' for descending." - }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer" - }, - "description": "Page number, 24 rows per page." - }, - { - "in": "query", - "name": "q", - "schema": { - "type": "string" - }, - "description": "Search within the authorized list." } ], "tags": [ - "Admin bookings" + "Admin event series" ], "security": [ { @@ -37841,7 +38104,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookableSpaceListResponse" + "$ref": "#/components/schemas/EventSeriesListResponse" } } }, @@ -37855,7 +38118,7 @@ } } }, - "description": "Invalid request." + "description": "Invalid recurring event series." }, "401": { "content": { @@ -37875,7 +38138,7 @@ } } }, - "description": "Permission denied." + "description": "Event management access denied." }, "404": { "content": { @@ -37885,13 +38148,33 @@ } } }, - "description": "Not found." + "description": "Event series not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Series state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." } } }, "post": { - "operationId": "api_v1_admin_makerspaces_spaces_create", - "summary": "Create a bookable space", + "operationId": "api_v1_admin_makerspaces_event_series_create", + "summary": "Create a recurring event series", "parameters": [ { "in": "path", @@ -37903,23 +38186,23 @@ } ], "tags": [ - "Admin bookings" + "Admin event series" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookableSpaceWrite" + "$ref": "#/components/schemas/EventSeriesWrite" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/BookableSpaceWrite" + "$ref": "#/components/schemas/EventSeriesWrite" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/BookableSpaceWrite" + "$ref": "#/components/schemas/EventSeriesWrite" } } }, @@ -37935,7 +38218,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookableSpaceAdmin" + "$ref": "#/components/schemas/EventSeriesMutationResponse" } } }, @@ -37949,7 +38232,7 @@ } } }, - "description": "Invalid request." + "description": "Invalid recurring event series." }, "401": { "content": { @@ -37969,7 +38252,7 @@ } } }, - "description": "Permission denied." + "description": "Event management access denied." }, "404": { "content": { @@ -37979,14 +38262,34 @@ } } }, - "description": "Not found." + "description": "Event series not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Series state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/staff-invitations": { + "/api/v1/admin/makerspaces/{makerspace_id}/event-series-collaborations/": { "get": { - "operationId": "api_v1_admin_makerspaces_staff_invitations_list", + "operationId": "api_v1_admin_makerspaces_event_series_collaborations_list", "parameters": [ { "in": "path", @@ -37995,19 +38298,146 @@ "type": "integer" }, "required": true - }, - { - "name": "page", - "required": false, + } + ], + "tags": [ + "Admin event series" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesCollaborationInbox" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid collaboration request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management access denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Series collaboration not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Collaboration state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/events/": { + "get": { + "operationId": "api_v1_admin_makerspaces_events_retrieve", + "summary": "List events in a makerspace", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { "in": "query", - "description": "A page number within the paginated result set.", + "name": "ordering", + "schema": { + "type": "string" + }, + "description": "Allowlisted sort field; prefix with '-' for descending." + }, + { + "in": "query", + "name": "page", "schema": { "type": "integer" + }, + "description": "Page number, 24 rows per page." + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Search within the authorized list." + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "cancelled", + "completed", + "draft", + "published" + ] } } ], "tags": [ - "Staff invitations" + "Admin events" ], "security": [ { @@ -38019,31 +38449,57 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedStaffInvitationList" + "$ref": "#/components/schemas/EventListWithDefaults" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } }, "post": { - "operationId": "api_v1_admin_makerspaces_staff_invitations_create", + "operationId": "api_v1_admin_makerspaces_events_create", + "summary": "Create a draft event", "parameters": [ { "in": "path", @@ -38055,23 +38511,23 @@ } ], "tags": [ - "Staff invitations" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StaffInvitationCreate" + "$ref": "#/components/schemas/EventWrite" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/StaffInvitationCreate" + "$ref": "#/components/schemas/EventWrite" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/StaffInvitationCreate" + "$ref": "#/components/schemas/EventWrite" } } }, @@ -38087,48 +38543,61 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StaffInvitationDelivery" + "$ref": "#/components/schemas/EventAdmin" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid event details." }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." - }, - "423": { - "description": "Makerspace writes are frozen for tenant migration." - }, - "503": { - "description": "A required dependency is unavailable." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/staff-invitations/{invitation_id}/resend": { - "post": { - "operationId": "api_v1_admin_makerspaces_staff_invitations_resend_create", + "/api/v1/admin/makerspaces/{makerspace_id}/evidence-retention": { + "get": { + "operationId": "api_v1_admin_makerspaces_evidence_retention_retrieve", + "summary": "Get a makerspace evidence retention and abandoned-upload policy", "parameters": [ - { - "in": "path", - "name": "invitation_id", - "schema": { - "type": "integer" - }, - "required": true - }, { "in": "path", "name": "makerspace_id", @@ -38139,7 +38608,7 @@ } ], "tags": [ - "Staff invitations" + "Evidence retention" ], "security": [ { @@ -38151,48 +38620,30 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StaffInvitationDelivery" + "$ref": "#/components/schemas/EvidenceRetentionPolicy" } } }, "description": "" }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, "401": { - "description": "Not signed in." + "description": "Authentication is required." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "description": "Active manage-makerspace permission is required." }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." - }, - "423": { - "description": "Makerspace writes are frozen for tenant migration." + "description": "Makerspace was not found in the actor's scope." }, "503": { - "description": "A required dependency is unavailable." + "description": "Deployment recovery is active." } } - } - }, - "/api/v1/admin/makerspaces/{makerspace_id}/staff-invitations/{invitation_id}/revoke": { - "post": { - "operationId": "api_v1_admin_makerspaces_staff_invitations_revoke_create", + }, + "patch": { + "operationId": "api_v1_admin_makerspaces_evidence_retention_partial_update", + "summary": "Partially update the evidence retention and abandoned-upload policy", "parameters": [ - { - "in": "path", - "name": "invitation_id", - "schema": { - "type": "integer" - }, - "required": true - }, { "in": "path", "name": "makerspace_id", @@ -38203,8 +38654,27 @@ } ], "tags": [ - "Staff invitations" + "Evidence retention" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedEvidenceRetentionPatch" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedEvidenceRetentionPatch" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedEvidenceRetentionPatch" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -38215,40 +38685,34 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StaffInvitation" + "$ref": "#/components/schemas/EvidenceRetentionPolicy" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "description": "Retention days are invalid." }, "401": { - "description": "Not signed in." + "description": "Authentication is required." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "description": "Active manage-makerspace permission is required." }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." - }, - "423": { - "description": "Makerspace writes are frozen for tenant migration." + "description": "Makerspace was not found in the actor's scope." }, "503": { - "description": "A required dependency is unavailable." + "description": "Deployment recovery is active." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/uploads/evidence-url": { + "/api/v1/admin/makerspaces/{makerspace_id}/evidence-retention/preview": { "post": { - "operationId": "api_v1_admin_makerspaces_uploads_evidence_url_create", - "description": "Base for ALL staff endpoints: authenticated + active staff + auto-scoped queryset.\n\nFuture phases subclass this so the invariant 'every staff query is makerspace-scoped'\nis enforced by default rather than by remembering to add a mixin (review fix #4). Add\n`required_action` + `HasMakerspaceAction` to a subclass for per-action checks.", + "operationId": "api_v1_admin_makerspaces_evidence_retention_preview_create", + "summary": "Preview evidence object expiry and abandoned-upload cleanup", "parameters": [ { "in": "path", @@ -38260,27 +38724,26 @@ } ], "tags": [ - "api" + "Evidence retention" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EvidenceUrlRequest" + "$ref": "#/components/schemas/EvidenceRetentionPreviewRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/EvidenceUrlRequest" + "$ref": "#/components/schemas/EvidenceRetentionPreviewRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/EvidenceUrlRequest" + "$ref": "#/components/schemas/EvidenceRetentionPreviewRequest" } } - }, - "required": true + } }, "security": [ { @@ -38288,32 +38751,39 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EvidenceUrlResponse" + "$ref": "#/components/schemas/EvidenceRetentionPreviewResponse" } } }, "description": "" }, "400": { - "description": "Invalid evidence upload request." + "description": "Preview limit is invalid." + }, + "401": { + "description": "Authentication is required." }, "403": { - "description": "Insufficient makerspace permission." + "description": "Active manage-makerspace permission is required." + }, + "404": { + "description": "Makerspace was not found in the actor's scope." }, "503": { - "description": "Evidence storage is unavailable." + "description": "Deployment recovery is active." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/waiver": { + "/api/v1/admin/makerspaces/{makerspace_id}/invitation-requests": { "get": { - "operationId": "api_v1_admin_makerspaces_waiver_retrieve", - "description": "Requires manage_members in this makerspace; staff role administration and waiver publishing require manage_makerspace.", + "operationId": "api_v1_admin_makerspaces_invitation_requests_list", + "description": "Requires manage_members in this makerspace. Staff role administration and membership plan definitions require manage_makerspace.", + "summary": "List invitation requests (optionally filtered by ?status=)", "parameters": [ { "in": "path", @@ -38337,7 +38807,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberWaiverResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/InvitationRequest" + } } } }, @@ -38392,33 +38865,21 @@ } }, "description": "" - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" } } - }, - "put": { - "operationId": "api_v1_admin_makerspaces_waiver_update", - "description": "Requires manage_makerspace in this makerspace to publish or clear waiver text.", + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/machine-materials": { + "get": { + "operationId": "api_v1_admin_makerspaces_machine_materials_list", "parameters": [ + { + "in": "query", + "name": "machine_type_id", + "schema": { + "type": "integer" + } + }, { "in": "path", "name": "makerspace_id", @@ -38426,30 +38887,20 @@ "type": "integer" }, "required": true + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } } ], "tags": [ - "Admin memberships" + "api" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WaiverPublish" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/WaiverPublish" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/WaiverPublish" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -38460,13 +38911,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberWaiverResponse" + "$ref": "#/components/schemas/PaginatedMaterialList" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -38476,7 +38927,7 @@ }, "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -38486,7 +38937,7 @@ }, "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -38496,7 +38947,7 @@ }, "description": "" }, - "404": { + "423": { "content": { "application/json": { "schema": { @@ -38506,7 +38957,7 @@ }, "description": "" }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -38516,7 +38967,7 @@ }, "description": "" }, - "423": { + "503": { "content": { "application/json": { "schema": { @@ -38526,23 +38977,21 @@ }, "description": "" }, - "503": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true } } }, "description": "" } } - } - }, - "/api/v1/admin/makerspaces/{makerspace_id}/walk-in-members": { - "get": { - "operationId": "api_v1_admin_makerspaces_walk_in_members_retrieve", - "summary": "Read walk-in admission questions", + }, + "post": { + "operationId": "api_v1_admin_makerspaces_machine_materials_create", "parameters": [ { "in": "path", @@ -38554,25 +39003,45 @@ } ], "tags": [ - "Admin memberships" + "api" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Material" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Material" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Material" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicAdmission" + "$ref": "#/components/schemas/Material" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -38582,7 +39051,7 @@ }, "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -38592,7 +39061,7 @@ }, "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -38602,7 +39071,7 @@ }, "description": "" }, - "404": { + "423": { "content": { "application/json": { "schema": { @@ -38612,7 +39081,7 @@ }, "description": "" }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -38622,7 +39091,7 @@ }, "description": "" }, - "423": { + "503": { "content": { "application/json": { "schema": { @@ -38632,22 +39101,32 @@ }, "description": "" }, - "503": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true } } }, "description": "" } } - }, - "post": { - "operationId": "api_v1_admin_makerspaces_walk_in_members_create", - "summary": "Create a walk-in member record", + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/machine-materials/{id}": { + "get": { + "operationId": "api_v1_admin_makerspaces_machine_materials_list_2", "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -38655,48 +39134,37 @@ "type": "integer" }, "required": true + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } } ], "tags": [ - "Admin memberships" + "api" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WalkInMemberCreate" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/WalkInMemberCreate" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/WalkInMemberCreate" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WalkInAdmissionResult" + "$ref": "#/components/schemas/PaginatedMaterialList" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -38706,7 +39174,7 @@ }, "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -38716,7 +39184,7 @@ }, "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -38726,7 +39194,7 @@ }, "description": "" }, - "404": { + "423": { "content": { "application/json": { "schema": { @@ -38736,7 +39204,7 @@ }, "description": "" }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -38746,7 +39214,7 @@ }, "description": "" }, - "423": { + "503": { "content": { "application/json": { "schema": { @@ -38756,23 +39224,21 @@ }, "description": "" }, - "503": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true } } }, "description": "" } } - } - }, - "/api/v1/admin/makerspaces/{id}": { - "get": { - "operationId": "api_v1_admin_makerspaces_retrieve", - "summary": "Retrieve a makerspace's settings", + }, + "patch": { + "operationId": "api_v1_admin_makerspaces_machine_materials_partial_update", "parameters": [ { "in": "path", @@ -38781,43 +39247,10 @@ "type": "integer" }, "required": true - } - ], - "tags": [ - "Admin makerspaces" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Makerspace" - } - } - }, - "description": "" - }, - "401": { - "description": "Authentication required." }, - "404": { - "description": "Makerspace not found, archived, not active, hidden from superadmins, or outside the actor's scope for this method." - } - } - }, - "patch": { - "operationId": "api_v1_admin_makerspaces_partial_update", - "description": "Needs manage_makerspace. `enabled_modules` is superadmin-owned and edited only in /control/ or the module console: a body carrying it is refused with 403.", - "summary": "Update a makerspace's settings", - "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -38825,23 +39258,23 @@ } ], "tags": [ - "Admin makerspaces" + "api" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedMakerspace" + "$ref": "#/components/schemas/PatchedMaterial" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedMakerspace" + "$ref": "#/components/schemas/PatchedMaterial" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedMakerspace" + "$ref": "#/components/schemas/PatchedMaterial" } } } @@ -38856,103 +39289,43 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Makerspace" + "$ref": "#/components/schemas/Material" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {}, - "properties": { - "theme_config": { - "oneOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "object", - "required": [ - "code", - "messages" - ], - "properties": { - "code": { - "type": "string", - "enum": [ - "theme_contrast" - ] - }, - "messages": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - ] - } - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Field validation errors. Contrast errors have theme_config: {code: 'theme_contrast', messages: string[]} with mode, failing pair, measured ratio and required ratio." - }, - "401": { - "description": "Authentication required." + "description": "" }, "403": { - "description": "The body carries `enabled_modules`." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Makerspace not found, archived, not active, hidden from superadmins, or outside the actor's scope for this method." - } - } - } - }, - "/api/v1/admin/member-cards/{id}/print.pdf": { - "post": { - "operationId": "api_v1_admin_member_cards_print.pdf_create", - "summary": "Print one card (CR80 PDF)", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin memberships" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { - "application/pdf": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "400": { + "423": { "content": { "application/json": { "schema": { @@ -38960,9 +39333,9 @@ } } }, - "description": "Invalid card input or membership module disabled." + "description": "" }, - "401": { + "429": { "content": { "application/json": { "schema": { @@ -38970,9 +39343,9 @@ } } }, - "description": "Authentication required." + "description": "" }, - "403": { + "503": { "content": { "application/json": { "schema": { @@ -38980,25 +39353,23 @@ } } }, - "description": "Missing member-card action or inactive account." + "description": "" }, - "404": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true } } }, - "description": "Not found in the permitted makerspace." + "description": "" } } - } - }, - "/api/v1/admin/member-cards/{id}/reissue": { - "post": { - "operationId": "api_v1_admin_member_cards_reissue_create", - "summary": "Reissue (rotate the QR of) a card", + }, + "delete": { + "operationId": "api_v1_admin_makerspaces_machine_materials_destroy", "parameters": [ { "in": "path", @@ -39007,48 +39378,39 @@ "type": "integer" }, "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true } ], "tags": [ - "Admin memberships" + "api" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MemberCardReissue" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/MemberCardReissue" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/MemberCardReissue" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "204": { + "description": "No response body" + }, + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberCard" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "400": { + "403": { "content": { "application/json": { "schema": { @@ -39056,9 +39418,9 @@ } } }, - "description": "Invalid card input or membership module disabled." + "description": "" }, - "401": { + "404": { "content": { "application/json": { "schema": { @@ -39066,9 +39428,9 @@ } } }, - "description": "Authentication required." + "description": "" }, - "403": { + "423": { "content": { "application/json": { "schema": { @@ -39076,9 +39438,9 @@ } } }, - "description": "Missing member-card action or inactive account." + "description": "" }, - "404": { + "429": { "content": { "application/json": { "schema": { @@ -39086,19 +39448,75 @@ } } }, - "description": "Not found in the permitted makerspace." + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + }, + "description": "" } } } }, - "/api/v1/admin/member-cards/{id}/revoke": { + "/api/v1/admin/makerspaces/{makerspace_id}/machine-service/consumable-pools": { + "get": { + "operationId": "api_v1_admin_makerspaces_machine_service_consumable_pools_list", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machine service" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PrinterPool" + } + } + } + }, + "description": "" + } + } + }, "post": { - "operationId": "api_v1_admin_member_cards_revoke_create", - "summary": "Revoke a card (redacts name and photo)", + "operationId": "api_v1_admin_makerspaces_machine_service_consumable_pools_create", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -39106,26 +39524,27 @@ } ], "tags": [ - "Admin memberships" + "Admin machine service" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberCardRevoke" + "$ref": "#/components/schemas/PrinterPoolCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MemberCardRevoke" + "$ref": "#/components/schemas/PrinterPoolCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MemberCardRevoke" + "$ref": "#/components/schemas/PrinterPoolCreate" } } - } + }, + "required": true }, "security": [ { @@ -39133,27 +39552,98 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberCard" + "$ref": "#/components/schemas/PrinterPool" } } }, "description": "" + } + } + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/machine-service/requests": { + "get": { + "operationId": "api_v1_admin_makerspaces_machine_service_requests_list", + "summary": "List machine service requests", + "parameters": [ + { + "in": "query", + "name": "bucket", + "schema": { + "type": "integer" + } }, - "400": { + { + "in": "query", + "name": "machine", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "machine_type", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "machine_type_id", + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "query", + "name": "queue", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string" + } + } + ], + "tags": [ + "Admin machine service" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineServiceRequest" + } } } }, - "description": "Invalid card input or membership module disabled." + "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { @@ -39161,19 +39651,18 @@ } } }, + "description": "Invalid service request input." + }, + "401": { "description": "Authentication required." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Missing member-card action or inactive account." + "description": "Machine management permission required." }, "404": { + "description": "Service request was not found." + }, + "409": { "content": { "application/json": { "schema": { @@ -39181,20 +39670,17 @@ } } }, - "description": "Not found in the permitted makerspace." + "description": "Service workflow conflict." } } - } - }, - "/api/v1/admin/membership-plans/{id}": { - "patch": { - "operationId": "api_v1_admin_membership_plans_partial_update", - "description": "Requires manage_makerspace in this makerspace; manage_members alone cannot change plan definitions or prices.", - "summary": "Update a membership plan", + }, + "post": { + "operationId": "api_v1_admin_makerspaces_machine_service_requests_create", + "summary": "Submit a machine service request for a member", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -39202,26 +39688,27 @@ } ], "tags": [ - "Admin memberships" + "Admin machine service" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedMembershipPlan" + "$ref": "#/components/schemas/MachineServiceSubmit" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedMembershipPlan" + "$ref": "#/components/schemas/MachineServiceSubmit" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedMembershipPlan" + "$ref": "#/components/schemas/MachineServiceSubmit" } } - } + }, + "required": true }, "security": [ { @@ -39229,11 +39716,11 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipPlan" + "$ref": "#/components/schemas/MachineServiceRequest" } } }, @@ -39247,19 +39734,18 @@ } } }, - "description": "" + "description": "Invalid service request input." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Authentication required." }, "403": { + "description": "Machine management permission required." + }, + "404": { + "description": "Service request was not found." + }, + "409": { "content": { "application/json": { "schema": { @@ -39267,23 +39753,108 @@ } } }, - "description": "" - }, - "404": { + "description": "Service workflow conflict." + } + } + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/machine-service/typed-manual-usage": { + "get": { + "operationId": "api_v1_admin_makerspaces_machine_service_typed_manual_usage_list", + "parameters": [ + { + "in": "query", + "name": "machine_type", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "machine_type_id", + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machine service" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "array", + "items": { + "$ref": "#/components/schemas/TypedManualUsageResponse" + } } } }, "description": "" + } + } + }, + "post": { + "operationId": "api_v1_admin_makerspaces_machine_service_typed_manual_usage_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machine service" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedManualUsage" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/TypedManualUsage" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/TypedManualUsage" + } + } }, - "409": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedManualUsageResponse" } } }, @@ -39292,13 +39863,12 @@ } } }, - "/api/v1/admin/membership-requests": { + "/api/v1/admin/makerspaces/{makerspace_id}/machine-types/{type_id}/intake": { "get": { - "operationId": "api_v1_admin_membership_requests_retrieve", - "description": "Requires manage_members in this makerspace; staff role administration and waiver publishing require manage_makerspace.", + "operationId": "api_v1_admin_makerspaces_machine_types_intake_retrieve", "parameters": [ { - "in": "query", + "in": "path", "name": "makerspace_id", "schema": { "type": "integer" @@ -39306,31 +39876,16 @@ "required": true }, { - "in": "query", - "name": "ordering", - "schema": { - "type": "string" - }, - "description": "Allowlisted sort field; prefix with '-' for descending." - }, - { - "in": "query", - "name": "page", + "in": "path", + "name": "type_id", "schema": { "type": "integer" }, - "description": "Page number, 24 rows per page." - }, - { - "in": "query", - "name": "state", - "schema": { - "type": "string" - } + "required": true } ], "tags": [ - "Admin memberships" + "api" ], "security": [ { @@ -39342,13 +39897,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipRequestListResponse" + "$ref": "#/components/schemas/Intake" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -39358,7 +39913,7 @@ }, "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -39368,7 +39923,7 @@ }, "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -39378,7 +39933,7 @@ }, "description": "" }, - "404": { + "423": { "content": { "application/json": { "schema": { @@ -39388,7 +39943,7 @@ }, "description": "" }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -39398,7 +39953,7 @@ }, "description": "" }, - "423": { + "503": { "content": { "application/json": { "schema": { @@ -39408,27 +39963,33 @@ }, "description": "" }, - "503": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true } } }, "description": "" } } - } - }, - "/api/v1/admin/membership-requests/{id}/approve": { - "post": { - "operationId": "api_v1_admin_membership_requests_approve_create", - "description": "Requires manage_members. Returns an admission request when payment is due, otherwise the activated membership.", + }, + "put": { + "operationId": "api_v1_admin_makerspaces_machine_types_intake_update", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "type_id", "schema": { "type": "integer" }, @@ -39436,23 +39997,23 @@ } ], "tags": [ - "Admin memberships" + "api" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApproveRequest" + "$ref": "#/components/schemas/Intake" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ApproveRequest" + "$ref": "#/components/schemas/Intake" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ApproveRequest" + "$ref": "#/components/schemas/Intake" } } }, @@ -39468,13 +40029,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipApprovalOutcome" + "$ref": "#/components/schemas/Intake" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -39484,7 +40045,7 @@ }, "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -39494,7 +40055,7 @@ }, "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -39504,7 +40065,7 @@ }, "description": "" }, - "404": { + "423": { "content": { "application/json": { "schema": { @@ -39514,7 +40075,7 @@ }, "description": "" }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -39524,7 +40085,7 @@ }, "description": "" }, - "423": { + "503": { "content": { "application/json": { "schema": { @@ -39534,11 +40095,12 @@ }, "description": "" }, - "503": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true } } }, @@ -39547,14 +40109,77 @@ } } }, - "/api/v1/admin/membership-requests/{id}/revoke": { + "/api/v1/admin/makerspaces/{makerspace_id}/machines/{machine_id}/maintenance/logs/": { + "get": { + "operationId": "api_v1_admin_makerspaces_machines_maintenance_logs_retrieve", + "summary": "List immutable machine maintenance logs", + "parameters": [ + { + "in": "path", + "name": "machine_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin maintenance" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceLogList" + } + } + }, + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + } + } + }, "post": { - "operationId": "api_v1_admin_membership_requests_revoke_create", - "description": "Requires manage_members in this makerspace; staff role administration and waiver publishing require manage_makerspace.", + "operationId": "api_v1_admin_makerspaces_machines_maintenance_logs_create", + "description": "When schedule_id is supplied, complete that schedule and set its next due date according to its recurrence anchor. Completion anchors add interval_days to the makerspace-local date of performed_at; calendar anchors advance the previous due date by whole intervals past completion. performed_at defaults to now; backdated entries use their recorded completion date.", + "summary": "Record completed machine maintenance", "parameters": [ { "in": "path", - "name": "id", + "name": "machine_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -39562,48 +40187,228 @@ } ], "tags": [ - "Admin memberships" + "Admin maintenance" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Revoke" + "$ref": "#/components/schemas/MaintenanceLogWrite" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/Revoke" + "$ref": "#/components/schemas/MaintenanceLogWrite" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/Revoke" + "$ref": "#/components/schemas/MaintenanceLogWrite" } } - } + }, + "required": true }, "security": [ { "jwtAuth": [] } ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceLog" + } + } + }, + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "409": { + "description": "Conflicts with the current state." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." + } + } + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/machines/{machine_id}/maintenance/schedules/": { + "get": { + "operationId": "api_v1_admin_makerspaces_machines_maintenance_schedules_retrieve", + "summary": "List machine maintenance schedules", + "parameters": [ + { + "in": "path", + "name": "machine_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin maintenance" + ], + "security": [ + { + "jwtAuth": [] + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipRequest" + "$ref": "#/components/schemas/MaintenanceScheduleList" + } + } + }, + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + } + } + }, + "post": { + "operationId": "api_v1_admin_makerspaces_machines_maintenance_schedules_create", + "summary": "Create a machine maintenance schedule", + "parameters": [ + { + "in": "path", + "name": "machine_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin maintenance" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceScheduleWrite" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MaintenanceScheduleWrite" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MaintenanceScheduleWrite" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceSchedule" } } }, "description": "" }, "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "409": { + "description": "Conflicts with the current state." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." + } + } + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/maintenance/feed": { + "get": { + "operationId": "api_v1_admin_makerspaces_maintenance_feed_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "api" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/FeedState" } } }, @@ -39639,7 +40444,7 @@ }, "description": "" }, - "409": { + "423": { "content": { "application/json": { "schema": { @@ -39649,7 +40454,7 @@ }, "description": "" }, - "423": { + "429": { "content": { "application/json": { "schema": { @@ -39668,19 +40473,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + }, + "description": "" } } - } - }, - "/api/v1/admin/membership-terms/{id}/cancel": { + }, "post": { - "operationId": "api_v1_admin_membership_terms_cancel_create", - "description": "Requires manage_members in this makerspace. Staff role administration and membership plan definitions require manage_makerspace.", - "summary": "Cancel an active term", + "operationId": "api_v1_admin_makerspaces_maintenance_feed_create", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -39688,7 +40500,7 @@ } ], "tags": [ - "Admin memberships" + "api" ], "security": [ { @@ -39700,13 +40512,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipTerm" + "$ref": "#/components/schemas/FeedIssued" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -39716,7 +40528,7 @@ }, "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -39726,7 +40538,7 @@ }, "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -39736,7 +40548,7 @@ }, "description": "" }, - "404": { + "423": { "content": { "application/json": { "schema": { @@ -39746,7 +40558,7 @@ }, "description": "" }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -39755,65 +40567,44 @@ } }, "description": "" - } - } - } - }, - "/api/v1/admin/memberships": { - "get": { - "operationId": "api_v1_admin_memberships_retrieve", - "description": "Requires manage_members in this makerspace; staff role administration and waiver publishing require manage_makerspace.", - "parameters": [ - { - "in": "query", - "name": "id", - "schema": { - "type": "integer" - }, - "description": "Select one membership within the authorized roster." }, - { - "in": "query", - "name": "makerspace_id", - "schema": { - "type": "integer" + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true + "description": "" }, - { - "in": "query", - "name": "ordering", - "schema": { - "type": "string" + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } }, - "description": "Allowlisted sort field; prefix with '-' for descending." - }, + "description": "" + } + } + }, + "delete": { + "operationId": "api_v1_admin_makerspaces_maintenance_feed_destroy", + "parameters": [ { - "in": "query", - "name": "page", + "in": "path", + "name": "makerspace_id", "schema": { "type": "integer" }, - "description": "Page number, 24 rows per page." - }, - { - "in": "query", - "name": "q", - "schema": { - "type": "string" - }, - "description": "Search within the authorized list." - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string" - } + "required": true } ], "tags": [ - "Admin memberships" + "api" ], "security": [ { @@ -39821,17 +40612,10 @@ } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MembershipListResponse" - } - } - }, - "description": "" + "204": { + "description": "No response body" }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -39841,7 +40625,7 @@ }, "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -39851,7 +40635,7 @@ }, "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -39861,7 +40645,7 @@ }, "description": "" }, - "404": { + "423": { "content": { "application/json": { "schema": { @@ -39871,7 +40655,7 @@ }, "description": "" }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -39881,7 +40665,7 @@ }, "description": "" }, - "423": { + "503": { "content": { "application/json": { "schema": { @@ -39891,11 +40675,12 @@ }, "description": "" }, - "503": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true } } }, @@ -39904,95 +40689,66 @@ } } }, - "/api/v1/admin/memberships/{id}": { - "delete": { - "operationId": "api_v1_admin_memberships_destroy", - "description": "Remove a single makerspace membership (un-assign a delegable role).\n\nScope contract (mirrors the create path's non-escalation model): a Space Manager may\nrevoke ONLY delegable-role memberships within their MANAGE_MAKERSPACE scope; a superadmin\nmay revoke any, except inside a superadmin-hidden makerspace (governance hard-block ->\n404). 404-before-403: out-of-scope existence is hidden as 404, a delegable-scope actor\naiming at a SPACE_MANAGER gets 403.", - "summary": "Revoke a makerspace staff membership", + "/api/v1/admin/makerspaces/{makerspace_id}/maintenance/feed/{raw_token}.ics": { + "get": { + "operationId": "api_v1_admin_makerspaces_maintenance_feed_.ics_retrieve", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, "required": true - } - ], - "tags": [ - "Admin users" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "204": { - "description": "Membership revoked." - }, - "403": { - "description": "A Space Manager membership can only be revoked by a superadmin." }, - "404": { - "description": "Membership not found or outside the actor's makerspace scope." - } - } - } - }, - "/api/v1/admin/memberships/{id}/capabilities": { - "patch": { - "operationId": "api_v1_admin_memberships_capabilities_partial_update", - "description": "Requires manage_members in this makerspace. Staff role administration and membership plan definitions require manage_makerspace.", - "parameters": [ { "in": "path", - "name": "id", + "name": "raw_token", "schema": { - "type": "integer" + "type": "string" }, "required": true } ], "tags": [ - "Admin memberships" + "api" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedMembershipCapabilities" + "responses": { + "200": { + "content": { + "text/calendar": { + "schema": { + "type": "string", + "format": "binary" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedMembershipCapabilities" + "description": "" + }, + "304": { + "description": "No response body" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedMembershipCapabilities" - } - } - } - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "" + }, + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMembership" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "400": { + "404": { "content": { "application/json": { "schema": { @@ -40002,7 +40758,7 @@ }, "description": "" }, - "401": { + "423": { "content": { "application/json": { "schema": { @@ -40012,7 +40768,7 @@ }, "description": "" }, - "403": { + "429": { "content": { "application/json": { "schema": { @@ -40022,7 +40778,7 @@ }, "description": "" }, - "404": { + "503": { "content": { "application/json": { "schema": { @@ -40032,11 +40788,12 @@ }, "description": "" }, - "409": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true } } }, @@ -40045,14 +40802,14 @@ } } }, - "/api/v1/admin/memberships/{id}/revoke": { - "post": { - "operationId": "api_v1_admin_memberships_revoke_create", - "description": "Requires manage_members in this makerspace; staff role administration and waiver publishing require manage_makerspace.", + "/api/v1/admin/makerspaces/{makerspace_id}/member-card-template": { + "get": { + "operationId": "api_v1_admin_makerspaces_member_card_template_retrieve", + "summary": "Read the card layout template", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -40062,25 +40819,6 @@ "tags": [ "Admin memberships" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Revoke" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/Revoke" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Revoke" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -40091,7 +40829,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMembership" + "$ref": "#/components/schemas/MemberCardTemplate" } } }, @@ -40105,7 +40843,7 @@ } } }, - "description": "" + "description": "Invalid card input or membership module disabled." }, "401": { "content": { @@ -40115,7 +40853,7 @@ } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { @@ -40125,7 +40863,7 @@ } } }, - "description": "" + "description": "Missing member-card action or inactive account." }, "404": { "content": { @@ -40135,49 +40873,17 @@ } } }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not found in the permitted makerspace." } } - } - }, - "/api/v1/admin/memberships/{id}/role": { - "patch": { - "operationId": "api_v1_admin_memberships_role_partial_update", - "description": "Requires manage_members in this makerspace; staff role administration and waiver publishing require manage_makerspace.", + }, + "put": { + "operationId": "api_v1_admin_makerspaces_member_card_template_update", + "summary": "Replace the card layout template", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -40191,17 +40897,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedRoleId" + "$ref": "#/components/schemas/MemberCardTemplate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedRoleId" + "$ref": "#/components/schemas/MemberCardTemplate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedRoleId" + "$ref": "#/components/schemas/MemberCardTemplate" } } } @@ -40216,7 +40922,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMembership" + "$ref": "#/components/schemas/MemberCardTemplate" } } }, @@ -40230,7 +40936,7 @@ } } }, - "description": "" + "description": "Invalid card input or membership module disabled." }, "401": { "content": { @@ -40240,7 +40946,7 @@ } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { @@ -40250,7 +40956,7 @@ } } }, - "description": "" + "description": "Missing member-card action or inactive account." }, "404": { "content": { @@ -40260,54 +40966,31 @@ } } }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not found in the permitted makerspace." } } } }, - "/api/v1/admin/memberships/{id}/terms": { + "/api/v1/admin/makerspaces/{makerspace_id}/member-cards": { "get": { - "operationId": "api_v1_admin_memberships_terms_list", - "description": "Requires manage_members in this makerspace. Staff role administration and membership plan definitions require manage_makerspace.", - "summary": "List the terms of a membership", + "operationId": "api_v1_admin_makerspaces_member_cards_list", + "summary": "List member cards", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string" + }, + "description": "active | revoked" } ], "tags": [ @@ -40325,7 +41008,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/MembershipTerm" + "$ref": "#/components/schemas/MemberCard" } } } @@ -40340,7 +41023,7 @@ } } }, - "description": "" + "description": "Invalid card input or membership module disabled." }, "401": { "content": { @@ -40350,7 +41033,7 @@ } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { @@ -40360,7 +41043,7 @@ } } }, - "description": "" + "description": "Missing member-card action or inactive account." }, "404": { "content": { @@ -40370,28 +41053,19 @@ } } }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not found in the permitted makerspace." } } - }, + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/member-cards.pdf": { "post": { - "operationId": "api_v1_admin_memberships_terms_create", - "description": "Requires manage_members in this makerspace. Staff role administration and membership plan definitions require manage_makerspace.", - "summary": "Open a term for a membership", + "operationId": "api_v1_admin_makerspaces_member_cards.pdf_create", + "summary": "Print a sheet of cards", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -40405,21 +41079,20 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipTermCreate" + "$ref": "#/components/schemas/MemberCardPrint" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MembershipTermCreate" + "$ref": "#/components/schemas/MemberCardPrint" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MembershipTermCreate" + "$ref": "#/components/schemas/MemberCardPrint" } } - }, - "required": true + } }, "security": [ { @@ -40427,11 +41100,12 @@ } ], "responses": { - "201": { + "200": { "content": { - "application/json": { + "application/pdf": { "schema": { - "$ref": "#/components/schemas/MembershipTerm" + "type": "string", + "format": "binary" } } }, @@ -40445,7 +41119,7 @@ } } }, - "description": "" + "description": "Invalid card input or membership module disabled." }, "401": { "content": { @@ -40455,7 +41129,7 @@ } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { @@ -40465,7 +41139,7 @@ } } }, - "description": "" + "description": "Missing member-card action or inactive account." }, "404": { "content": { @@ -40475,29 +41149,27 @@ } } }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not found in the permitted makerspace." } } } }, - "/api/v1/admin/memberships/{id}/unverify": { + "/api/v1/admin/makerspaces/{makerspace_id}/member-cards/{membership_id}/issue": { "post": { - "operationId": "api_v1_admin_memberships_unverify_create", - "description": "Requires manage_members or an active membership with can_verify in this makerspace. Delegated verifiers cannot unverify themselves.", + "operationId": "api_v1_admin_makerspaces_member_cards_issue_create", + "summary": "Issue a card to a membership", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "membership_id", "schema": { "type": "integer" }, @@ -40507,17 +41179,36 @@ "tags": [ "Admin memberships" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberCardIssue" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MemberCardIssue" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MemberCardIssue" + } + } + } + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMembership" + "$ref": "#/components/schemas/MemberCard" } } }, @@ -40531,7 +41222,7 @@ } } }, - "description": "" + "description": "Invalid card input or membership module disabled." }, "401": { "content": { @@ -40541,7 +41232,7 @@ } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { @@ -40551,7 +41242,7 @@ } } }, - "description": "" + "description": "Missing member-card action or inactive account." }, "404": { "content": { @@ -40561,29 +41252,19 @@ } } }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not found in the permitted makerspace." } } } }, - "/api/v1/admin/memberships/{id}/verify": { + "/api/v1/admin/makerspaces/{makerspace_id}/member-cards/resolve": { "post": { - "operationId": "api_v1_admin_memberships_verify_create", - "description": "Requires manage_members or an active membership with can_verify in this makerspace. Delegated verifiers cannot verify themselves.", + "operationId": "api_v1_admin_makerspaces_member_cards_resolve_create", + "summary": "Resolve a scanned member card to minimal identity", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -40593,6 +41274,26 @@ "tags": [ "Admin memberships" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberCardResolve" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MemberCardResolve" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MemberCardResolve" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -40603,7 +41304,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMembership" + "$ref": "#/components/schemas/MemberCardResolveResult" } } }, @@ -40617,7 +41318,7 @@ } } }, - "description": "" + "description": "Invalid card input or membership module disabled." }, "401": { "content": { @@ -40627,7 +41328,7 @@ } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { @@ -40637,7 +41338,7 @@ } } }, - "description": "" + "description": "Missing member-card action or inactive account." }, "404": { "content": { @@ -40647,28 +41348,19 @@ } } }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not found in the permitted makerspace." } } } }, - "/api/v1/admin/memberships/{id}/waiver/witness": { - "post": { - "operationId": "api_v1_admin_memberships_waiver_witness_create", + "/api/v1/admin/makerspaces/{makerspace_id}/member-claim-codes": { + "get": { + "operationId": "api_v1_admin_makerspaces_member_claim_codes_list", + "summary": "List active physically handed member claim codes", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -40688,7 +41380,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WitnessWaiverResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberClaimCode" + } } } }, @@ -40745,16 +41440,14 @@ "description": "" } } - } - }, - "/api/v1/admin/organization-invitations/{id}/": { - "delete": { - "operationId": "api_v1_admin_organization_invitations_destroy", - "summary": "Revoke an unused organization invitation", + }, + "post": { + "operationId": "api_v1_admin_makerspaces_member_claim_codes_create", + "summary": "Issue a claim code to an eligible walk-in member", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -40762,28 +41455,45 @@ } ], "tags": [ - "Admin organizations" + "Admin memberships" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberClaimCodeIssueRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MemberClaimCodeIssueRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MemberClaimCodeIssueRequest" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "204": { - "description": "No response body" - }, - "401": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/MemberClaimCodeIssueResponse" } } }, - "description": "Authentication is required." + "description": "" }, - "403": { + "400": { "content": { "application/json": { "schema": { @@ -40791,9 +41501,9 @@ } } }, - "description": "Organization authority is required." + "description": "" }, - "404": { + "401": { "content": { "application/json": { "schema": { @@ -40801,12 +41511,9 @@ } } }, - "description": "Organization not found." - }, - "400": { - "description": "Invalid organization data." + "description": "" }, - "409": { + "403": { "content": { "application/json": { "schema": { @@ -40814,35 +41521,9 @@ } } }, - "description": "Organization state conflict." - } - } - } - }, - "/api/v1/admin/organizations/": { - "get": { - "operationId": "admin_organizations_list", - "summary": "List organizations visible to the actor", - "tags": [ - "Admin organizations" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OrganizationList" - } - } - }, "description": "" }, - "401": { + "404": { "content": { "application/json": { "schema": { @@ -40850,9 +41531,9 @@ } } }, - "description": "Authentication is required." + "description": "" }, - "403": { + "409": { "content": { "application/json": { "schema": { @@ -40860,9 +41541,9 @@ } } }, - "description": "Organization authority is required." + "description": "" }, - "404": { + "429": { "content": { "application/json": { "schema": { @@ -40870,47 +41551,47 @@ } } }, - "description": "Organization not found." + "description": "Issue rate limit exceeded." } } - }, + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/member-claim-codes/{claim_id}/revoke": { "post": { - "operationId": "api_v1_admin_organizations_create", - "summary": "Create an organization (superadmin only)", - "tags": [ - "Admin organizations" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OrganizationCreate" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/OrganizationCreate" - } + "operationId": "api_v1_admin_makerspaces_member_claim_codes_revoke_create", + "summary": "Revoke a member claim code and its bound session", + "parameters": [ + { + "in": "path", + "name": "claim_id", + "schema": { + "type": "integer" }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/OrganizationCreate" - } - } + "required": true }, - "required": true - }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin memberships" + ], "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrganizationDetail" + "$ref": "#/components/schemas/MemberClaimCode" } } }, @@ -40920,193 +41601,71 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" - }, - "purpose": { - "type": "string" - } - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Validation failed, or the makerspace module this route belongs to is off." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" - }, - "purpose": { - "type": "string" - } - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Not signed in." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" - }, - "purpose": { - "type": "string" - } - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "409": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" - }, - "purpose": { - "type": "string" - } - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Conflicts with the current state." + "description": "" } } } }, - "/api/v1/admin/organizations/{organization_id}/analytics/{report_key}": { + "/api/v1/admin/makerspaces/{makerspace_id}/membership-admission": { "get": { - "operationId": "api_v1_admin_organizations_analytics_retrieve", - "description": "Requires an active organization membership carrying the report's action. Authorization and the owned-makerspace set are resolved server-side; a combined total is always returned together with its per-makerspace breakdown.", - "summary": "Get organization analytics report", + "operationId": "api_v1_admin_makerspaces_membership_admission_retrieve", + "description": "Requires manage_makerspace; settings are separate from manage_members.", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "organization_id", + "name": "makerspace_id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "report_key", - "schema": { - "type": "string", - "enum": [ - "active-loans", - "booking-utilization", - "damaged-lost", - "damaged-missing", - "event-attendance", - "evidence-compliance", - "fablab-health", - "machine-usage", - "maintenance-activity", - "member-activity", - "most-lent", - "payment-cash-book", - "payment-reconciliation", - "qr-scans", - "recently-added", - "returns", - "summary", - "taken-items", - "top-borrowers" - ] - }, - "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" - ] - } } ], "tags": [ - "Analytics" + "Admin memberships" ], "security": [ { @@ -41118,7 +41677,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrganizationReportResponse" + "$ref": "#/components/schemas/AdmissionSettings" } } }, @@ -41128,79 +41687,43 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid or excluded organization report." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Organization or report not found." - } - } - } - }, - "/api/v1/admin/organizations/{id}/": { - "get": { - "operationId": "admin_organizations_retrieve", - "summary": "Retrieve organization governance details", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin organizations" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OrganizationDetail" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "401": { + "409": { "content": { "application/json": { "schema": { @@ -41208,9 +41731,9 @@ } } }, - "description": "Authentication is required." + "description": "" }, - "403": { + "423": { "content": { "application/json": { "schema": { @@ -41218,9 +41741,9 @@ } } }, - "description": "Organization authority is required." + "description": "" }, - "404": { + "503": { "content": { "application/json": { "schema": { @@ -41228,17 +41751,17 @@ } } }, - "description": "Organization not found." + "description": "" } } }, "patch": { - "operationId": "api_v1_admin_organizations_partial_update", - "summary": "Update an organization public profile", + "operationId": "api_v1_admin_makerspaces_membership_admission_partial_update", + "description": "Requires manage_makerspace. Paid admissions require charge tracking, not an online gateway.", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -41246,23 +41769,23 @@ } ], "tags": [ - "Admin organizations" + "Admin memberships" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedOrganizationProfileUpdate" + "$ref": "#/components/schemas/PatchedAdmissionSettings" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedOrganizationProfileUpdate" + "$ref": "#/components/schemas/PatchedAdmissionSettings" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedOrganizationProfileUpdate" + "$ref": "#/components/schemas/PatchedAdmissionSettings" } } } @@ -41277,7 +41800,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrganizationDetail" + "$ref": "#/components/schemas/AdmissionSettings" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -41291,7 +41824,7 @@ } } }, - "description": "Authentication is required." + "description": "" }, "403": { "content": { @@ -41301,7 +41834,7 @@ } } }, - "description": "Organization authority is required." + "description": "" }, "404": { "content": { @@ -41311,10 +41844,7 @@ } } }, - "description": "Organization not found." - }, - "400": { - "description": "Invalid organization data." + "description": "" }, "409": { "content": { @@ -41324,19 +41854,40 @@ } } }, - "description": "Organization state conflict." + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/organizations/{id}/invitations/": { + "/api/v1/admin/makerspaces/{makerspace_id}/membership-plans": { "get": { - "operationId": "api_v1_admin_organizations_invitations_retrieve", - "summary": "List organization invitations without bearer tokens", + "operationId": "api_v1_admin_makerspaces_membership_plans_list", + "description": "Requires manage_members in this makerspace. Staff role administration and membership plan definitions require manage_makerspace.", + "summary": "List membership plans", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -41344,7 +41895,7 @@ } ], "tags": [ - "Admin organizations" + "Admin memberships" ], "security": [ { @@ -41356,7 +41907,20 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrganizationInvitationList" + "type": "array", + "items": { + "$ref": "#/components/schemas/MembershipPlan" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -41370,7 +41934,7 @@ } } }, - "description": "Authentication is required." + "description": "" }, "403": { "content": { @@ -41380,7 +41944,7 @@ } } }, - "description": "Organization authority is required." + "description": "" }, "404": { "content": { @@ -41390,17 +41954,28 @@ } } }, - "description": "Organization not found." + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } }, "post": { - "operationId": "api_v1_admin_organizations_invitations_create", - "summary": "Create a single-use organization invitation", + "operationId": "api_v1_admin_makerspaces_membership_plans_create", + "description": "Requires manage_makerspace in this makerspace; manage_members alone cannot change plan definitions or prices.", + "summary": "Create a membership plan", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -41408,26 +41983,27 @@ } ], "tags": [ - "Admin organizations" + "Admin memberships" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrganizationInvitationCreate" + "$ref": "#/components/schemas/MembershipPlan" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/OrganizationInvitationCreate" + "$ref": "#/components/schemas/MembershipPlan" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/OrganizationInvitationCreate" + "$ref": "#/components/schemas/MembershipPlan" } } - } + }, + "required": true }, "security": [ { @@ -41439,13 +42015,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrganizationInvitationCreated" + "$ref": "#/components/schemas/MembershipPlan" } } }, "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { @@ -41453,9 +42029,9 @@ } } }, - "description": "Authentication is required." + "description": "" }, - "403": { + "401": { "content": { "application/json": { "schema": { @@ -41463,9 +42039,9 @@ } } }, - "description": "Organization authority is required." + "description": "" }, - "404": { + "403": { "content": { "application/json": { "schema": { @@ -41473,12 +42049,9 @@ } } }, - "description": "Organization not found." - }, - "400": { - "description": "Invalid organization data." + "description": "" }, - "409": { + "404": { "content": { "application/json": { "schema": { @@ -41486,18 +42059,29 @@ } } }, - "description": "Organization state conflict." + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/organizations/{id}/makerspaces/": { + "/api/v1/admin/makerspaces/{makerspace_id}/memberships": { "get": { - "operationId": "api_v1_admin_organizations_makerspaces_retrieve", + "operationId": "api_v1_admin_makerspaces_memberships_list", + "description": "Requires manage_members in this makerspace. Staff creation and role administration require manage_makerspace.", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -41505,7 +42089,7 @@ } ], "tags": [ - "Admin organizations" + "Admin memberships" ], "security": [ { @@ -41517,7 +42101,20 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrganizationLinkPage" + "type": "array", + "items": { + "$ref": "#/components/schemas/MembershipList" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -41527,76 +42124,51 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" - }, - "purpose": { - "type": "string" - } - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Not signed in." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" - }, - "purpose": { - "type": "string" - } - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "description": "" }, "404": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" - }, - "purpose": { - "type": "string" - } - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } }, "post": { - "operationId": "api_v1_admin_organizations_makerspaces_create", + "operationId": "api_v1_admin_makerspaces_memberships_create", + "description": "Requires manage_makerspace. Create or reattach staff with exactly one of role_id or role_ids (the complete set). Space Manager and Member are exclusive; unknown or foreign IDs return 404.", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -41604,23 +42176,23 @@ } ], "tags": [ - "Admin organizations" + "Admin memberships" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrganizationLinkWrite" + "$ref": "#/components/schemas/MembershipCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/OrganizationLinkWrite" + "$ref": "#/components/schemas/MembershipCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/OrganizationLinkWrite" + "$ref": "#/components/schemas/MembershipCreate" } } }, @@ -41632,11 +42204,11 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrganizationLink" + "$ref": "#/components/schemas/MembershipList" } } }, @@ -41647,165 +42219,63 @@ "application/json": { "schema": { "type": "object", - "additionalProperties": true, - "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" - }, - "purpose": { - "type": "string" - } - } + "additionalProperties": {} } } }, - "description": "Validation failed, or the makerspace module this route belongs to is off." + "description": "Field validation errors. Provide exactly one of role_id or role_ids; the set must be nonempty, and Space Manager and Member are exclusive." }, "401": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" - }, - "purpose": { - "type": "string" - } - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Not signed in." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" - }, - "purpose": { - "type": "string" - } - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "description": "" }, "404": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" - }, - "purpose": { - "type": "string" - } - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "description": "" }, "409": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" - }, - "purpose": { - "type": "string" - } - } - } - } - }, - "description": "Conflicts with the current state." - }, - "423": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" - }, - "purpose": { - "type": "string" - } - } - } - } - }, - "description": "Makerspace writes are frozen for tenant migration." - }, - "503": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" - }, - "purpose": { - "type": "string" - } - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "A required dependency is unavailable." + "description": "" } } } }, - "/api/v1/admin/organizations/{id}/makerspaces/{makerspace_id}/": { - "delete": { - "operationId": "api_v1_admin_organizations_makerspaces_destroy", + "/api/v1/admin/makerspaces/{makerspace_id}/memberships/{membership_id}/machine-scope": { + "get": { + "operationId": "api_v1_admin_makerspaces_memberships_machine_scope_retrieve", + "description": "Requires manage_makerspace in this makerspace.", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -41813,7 +42283,7 @@ }, { "in": "path", - "name": "makerspace_id", + "name": "membership_id", "schema": { "type": "integer" }, @@ -41821,7 +42291,7 @@ } ], "tags": [ - "Admin organizations" + "Admin memberships" ], "security": [ { @@ -41829,174 +42299,83 @@ } ], "responses": { - "204": { - "description": "No response body" - }, - "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" - }, - "purpose": { - "type": "string" - } - } - } - } - }, - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { + "200": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" - }, - "purpose": { - "type": "string" - } - } + "$ref": "#/components/schemas/MemberMachineScope" } } }, - "description": "Not signed in." + "description": "" }, - "403": { + "400": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" - }, - "purpose": { - "type": "string" - } - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "description": "" }, - "404": { + "401": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" - }, - "purpose": { - "type": "string" - } - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "description": "" }, - "409": { + "403": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" - }, - "purpose": { - "type": "string" - } - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Conflicts with the current state." + "description": "" }, - "423": { + "404": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" - }, - "purpose": { - "type": "string" - } - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace writes are frozen for tenant migration." + "description": "" }, - "503": { + "409": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" - }, - "purpose": { - "type": "string" - } - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "A required dependency is unavailable." + "description": "" } } - } - }, - "/api/v1/admin/organizations/{id}/memberships/": { - "get": { - "operationId": "api_v1_admin_organizations_memberships_retrieve", - "summary": "List organization memberships", + }, + "put": { + "operationId": "api_v1_admin_makerspaces_memberships_machine_scope_update", + "description": "Requires manage_makerspace. Replaces the member's complete type scope; exempt or non-Machine-Manager memberships return 409.", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "membership_id", "schema": { "type": "integer" }, @@ -42004,8 +42383,28 @@ } ], "tags": [ - "Admin organizations" + "Admin memberships" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberMachineScopeWrite" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MemberMachineScopeWrite" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MemberMachineScopeWrite" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -42016,7 +42415,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrganizationMembershipList" + "$ref": "#/components/schemas/MemberMachineScope" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -42030,7 +42439,7 @@ } } }, - "description": "Authentication is required." + "description": "" }, "403": { "content": { @@ -42040,7 +42449,7 @@ } } }, - "description": "Organization authority is required." + "description": "" }, "404": { "content": { @@ -42050,19 +42459,65 @@ } } }, - "description": "Organization not found." + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/organized-events/": { - "get": { - "operationId": "api_v1_admin_organized_events_retrieve", - "description": "List the events this actor's organizations organize, across venues.\n\nOrganizer authority is deliberately per-event and grants nothing over the venue, so an\norganizer at a venue their organization is not linked to cannot use the venue's event\nlist -- which left the per-event endpoints reachable only by someone who already knew a\ndatabase id. This is the discoverable surface for that authority: it lists exactly the\nevents the organizer predicate matches and confers no venue authority whatsoever.", - "summary": "List events organized by the actor's organizations", + "/api/v1/admin/makerspaces/{makerspace_id}/memberships/{membership_id}/role": { + "patch": { + "operationId": "api_v1_admin_makerspaces_memberships_role_partial_update", + "description": "Requires manage_makerspace. Replace the complete role set using exactly one of role_id or role_ids. Duplicate IDs are ignored; Space Manager and Member are exclusive. Unknown or foreign IDs return 404.", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "membership_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Events" + "Admin memberships" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedMembershipRoleAssign" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedMembershipRoleAssign" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedMembershipRoleAssign" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -42073,12 +42528,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventListResponse" + "$ref": "#/components/schemas/MembershipList" } } }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Field validation errors. Provide exactly one of role_id or role_ids; the set must be nonempty, and Space Manager and Member are exclusive." + }, "401": { "content": { "application/json": { @@ -42108,26 +42574,45 @@ } }, "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/platform/backup-destinations": { + "/api/v1/admin/makerspaces/{makerspace_id}/report-schedules": { "get": { - "operationId": "api_v1_admin_platform_backup_destinations_retrieve", - "summary": "List offsite backup destinations", + "operationId": "api_v1_admin_makerspaces_report_schedules_list", + "summary": "List report schedules", "parameters": [ { - "in": "query", - "name": "page", + "in": "path", + "name": "makerspace_id", "schema": { "type": "integer" }, - "description": "Page number (24 rows per page)." + "required": true + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } } ], "tags": [ - "Backup destinations" + "Reports" ], "security": [ { @@ -42139,128 +42624,81 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BackupDestinationPage" + "$ref": "#/components/schemas/PaginatedReportScheduleList" } } }, "description": "" }, - "404": { - "description": "The requested resource does not exist in the actor's scope." + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Invalid schedule or module disabled." }, "401": { - "description": "Authentication is required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "The authenticated actor is not authorized." - } - } - }, - "post": { - "operationId": "api_v1_admin_platform_backup_destinations_create", - "summary": "Register an S3-compatible offsite backup destination", - "tags": [ - "Backup destinations" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BackupDestination" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/BackupDestination" + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/BackupDestination" - } - } + "description": "Makerspace or schedule not found." }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "201": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BackupDestination" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" - }, - "400": { - "description": "The request is invalid for the current lifecycle state." - }, - "401": { - "description": "Authentication is required." + "description": "Tenant writes are frozen." }, - "403": { - "description": "The authenticated actor is not authorized." - } - } - } - }, - "/api/v1/admin/platform/backup-destinations/{destination_id}": { - "get": { - "operationId": "api_v1_admin_platform_backup_destinations_retrieve_2", - "summary": "Get one offsite backup destination", - "parameters": [ - { - "in": "path", - "name": "destination_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Backup destinations" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BackupDestination" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" - }, - "404": { - "description": "The requested resource does not exist in the actor's scope." - }, - "401": { - "description": "Authentication is required." - }, - "403": { - "description": "The authenticated actor is not authorized." + "description": "Tenant write gate unavailable." } } }, - "patch": { - "operationId": "api_v1_admin_platform_backup_destinations_partial_update", - "summary": "Update or rotate credentials of an offsite destination", + "post": { + "operationId": "api_v1_admin_makerspaces_report_schedules_create", + "summary": "Create report schedule", "parameters": [ { "in": "path", - "name": "destination_id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -42268,26 +42706,27 @@ } ], "tags": [ - "Backup destinations" + "Reports" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedBackupDestination" + "$ref": "#/components/schemas/ReportSchedule" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedBackupDestination" + "$ref": "#/components/schemas/ReportSchedule" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedBackupDestination" + "$ref": "#/components/schemas/ReportSchedule" } } - } + }, + "required": true }, "security": [ { @@ -42295,39 +42734,87 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BackupDestination" + "$ref": "#/components/schemas/ReportSchedule" } } }, "description": "" }, "400": { - "description": "The request is invalid for the current lifecycle state." - }, - "404": { - "description": "The requested resource does not exist in the actor's scope." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Invalid schedule or module disabled." }, "401": { - "description": "Authentication is required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "The authenticated actor is not authorized." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or schedule not found." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Tenant writes are frozen." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Tenant write gate unavailable." } } } }, - "/api/v1/admin/platform/backup-destinations/{destination_id}/retire": { - "post": { - "operationId": "api_v1_admin_platform_backup_destinations_retire_create", - "summary": "Retire a destination: stop pushing and wipe its stored credentials", + "/api/v1/admin/makerspaces/{makerspace_id}/roles": { + "get": { + "operationId": "api_v1_admin_makerspaces_roles_list", + "description": "Requires manage_makerspace for all roles, or manage_members for action-less community roles only.", "parameters": [ { "in": "path", - "name": "destination_id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -42335,7 +42822,7 @@ } ], "tags": [ - "Backup destinations" + "Admin roles" ], "security": [ { @@ -42347,80 +42834,75 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BackupDestination" + "type": "array", + "items": { + "$ref": "#/components/schemas/Role" + } } } }, "description": "" }, "400": { - "description": "The request is invalid for the current lifecycle state." - }, - "404": { - "description": "The requested resource does not exist in the actor's scope." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "401": { - "description": "Authentication is required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "The authenticated actor is not authorized." - } - } - } - }, - "/api/v1/admin/platform/backup-destinations/{destination_id}/test": { - "post": { - "operationId": "api_v1_admin_platform_backup_destinations_test_create", - "summary": "Probe write, read-back and delete on a destination", - "parameters": [ - { - "in": "path", - "name": "destination_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Backup destinations" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DestinationProbe" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, "404": { - "description": "The requested resource does not exist in the actor's scope." - }, - "401": { - "description": "Authentication is required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, - "403": { - "description": "The authenticated actor is not authorized." + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/platform/backup-destinations/{destination_id}/uploads": { + "/api/v1/admin/makerspaces/{makerspace_id}/saved-report-views": { "get": { - "operationId": "api_v1_admin_platform_backup_destinations_uploads_retrieve", - "summary": "List the upload journal of one destination", + "operationId": "api_v1_admin_makerspaces_saved_report_views_retrieve", "parameters": [ { "in": "path", - "name": "destination_id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -42431,12 +42913,11 @@ "name": "page", "schema": { "type": "integer" - }, - "description": "Page number (24 rows per page)." + } } ], "tags": [ - "Backup destinations" + "Reports" ], "security": [ { @@ -42448,114 +42929,108 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BackupDestinationUploadPage" + "$ref": "#/components/schemas/SavedReportViewList" } } }, "description": "" }, - "404": { - "description": "The requested resource does not exist in the actor's scope." + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "" }, "401": { - "description": "Authentication is required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "" }, "403": { - "description": "The authenticated actor is not authorized." - } - } - } - }, - "/api/v1/admin/platform/backup-destinations/push": { - "post": { - "operationId": "api_v1_admin_platform_backup_destinations_push_create", - "summary": "Start an offsite push now instead of waiting for the schedule", - "tags": [ - "Backup destinations" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DestinationDispatch" + "$ref": "#/components/schemas/ReportError" } } }, "description": "" }, - "503": { - "description": "The backup worker is unavailable." - }, - "401": { - "description": "Authentication is required." - }, - "403": { - "description": "The authenticated actor is not authorized." - } - } - } - }, - "/api/v1/admin/platform/backup-settings": { - "get": { - "operationId": "api_v1_admin_platform_backup_settings_retrieve", - "summary": "Get deployment backup settings", - "tags": [ - "Backup" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlatformBackupSettings" + "$ref": "#/components/schemas/ReportError" } } }, "description": "" }, - "401": { - "description": "Authentication is required." + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "" }, - "403": { - "description": "The authenticated actor is not authorized." + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "" } } }, - "patch": { - "operationId": "api_v1_admin_platform_backup_settings_partial_update", - "summary": "Update deployment backup settings", + "post": { + "operationId": "api_v1_admin_makerspaces_saved_report_views_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Backup" + "Reports" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedPlatformBackupSettings" + "$ref": "#/components/schemas/SavedReportView" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedPlatformBackupSettings" + "$ref": "#/components/schemas/SavedReportView" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedPlatformBackupSettings" + "$ref": "#/components/schemas/SavedReportView" } } - } + }, + "required": true }, "security": [ { @@ -42563,342 +43038,219 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlatformBackupSettings" + "$ref": "#/components/schemas/SavedReportView" } } }, "description": "" }, "400": { - "description": "The request is invalid for the current lifecycle state." - }, - "401": { - "description": "Authentication is required." - }, - "403": { - "description": "The authenticated actor is not authorized." - } - } - } - }, - "/api/v1/admin/platform/backups": { - "get": { - "operationId": "api_v1_admin_platform_backups_list", - "summary": "List full-deployment backup archives", - "tags": [ - "Backup" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BackupArchive" - } + "$ref": "#/components/schemas/ReportError" } } }, "description": "" }, "401": { - "description": "Authentication is required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "" }, "403": { - "description": "The authenticated actor is not authorized." - } - } - }, - "post": { - "operationId": "api_v1_admin_platform_backups_create", - "summary": "Request an age-encrypted full-deployment backup", - "tags": [ - "Backup" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BackupArchive" + "$ref": "#/components/schemas/ReportError" } } }, "description": "" }, - "503": { - "description": "The backup worker is unavailable." - }, - "401": { - "description": "Authentication is required." + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "" }, - "403": { - "description": "The authenticated actor is not authorized." + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/platform/backups/{archive_id}/rehydrate": { - "post": { - "operationId": "api_v1_admin_platform_backups_rehydrate_create", - "summary": "Restore an archive's primary bytes from a verified offsite copy", + "/api/v1/admin/makerspaces/{makerspace_id}/saved-report-views/{id}": { + "get": { + "operationId": "api_v1_admin_makerspaces_saved_report_views_retrieve_2", "parameters": [ { "in": "path", - "name": "archive_id", + "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" }, "required": true } ], "tags": [ - "Backup destinations" + "Reports" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Rehydrate" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/Rehydrate" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Rehydrate" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DestinationDispatch" + "$ref": "#/components/schemas/SavedReportView" } } }, "description": "" }, "400": { - "description": "The request is invalid for the current lifecycle state." - }, - "404": { - "description": "The requested resource does not exist in the actor's scope." - }, - "503": { - "description": "The backup worker is unavailable." - }, - "401": { - "description": "Authentication is required." - }, - "403": { - "description": "The authenticated actor is not authorized." - } - } - } - }, - "/api/v1/admin/platform/dashboard": { - "get": { - "operationId": "api_v1_admin_platform_dashboard_retrieve", - "summary": "Read platform operations dashboard", - "tags": [ - "Platform" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlatformDashboard" + "$ref": "#/components/schemas/ReportError" } } }, "description": "" }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - } - } - } - }, - "/api/v1/admin/platform/email-settings": { - "get": { - "operationId": "api_v1_admin_platform_email_settings_retrieve", - "summary": "Retrieve or update platform email settings", - "tags": [ - "Platform" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlatformEmailSettings" + "$ref": "#/components/schemas/ReportError" } } }, "description": "" }, - "401": { - "description": "Not signed in." - }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - } - } - }, - "patch": { - "operationId": "api_v1_admin_platform_email_settings_partial_update", - "summary": "Retrieve or update platform email settings", - "tags": [ - "Platform" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedPlatformEmailSettings" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedPlatformEmailSettings" + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedPlatformEmailSettings" - } - } - } - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "" + }, + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlatformEmailSettings" + "$ref": "#/components/schemas/ReportError" } } }, "description": "" }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - } - } - } - }, - "/api/v1/admin/platform/payment-settings": { - "get": { - "operationId": "api_v1_admin_platform_payment_settings_retrieve", - "summary": "Retrieve or update platform Stripe Connect settings", - "tags": [ - "Platform" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlatformStripeConnectSettings" + "$ref": "#/components/schemas/ReportError" } } }, "description": "" - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Self-hosted deployment: Stripe Connect is a managed-only surface." } } }, "patch": { - "operationId": "api_v1_admin_platform_payment_settings_partial_update", - "summary": "Retrieve or update platform Stripe Connect settings", + "operationId": "api_v1_admin_makerspaces_saved_report_views_partial_update", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Platform" + "Reports" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedPlatformStripeConnectSettings" + "$ref": "#/components/schemas/PatchedSavedReportView" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedPlatformStripeConnectSettings" + "$ref": "#/components/schemas/PatchedSavedReportView" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedPlatformStripeConnectSettings" + "$ref": "#/components/schemas/PatchedSavedReportView" } } } @@ -42913,68 +43265,96 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlatformStripeConnectSettings" + "$ref": "#/components/schemas/SavedReportView" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "" }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "" }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "" }, "404": { - "description": "Self-hosted deployment: Stripe Connect is a managed-only surface." - } - } - } - }, - "/api/v1/admin/platform/problems": { - "get": { - "operationId": "api_v1_admin_platform_problems_retrieve", - "summary": "Run read-only platform problem checks on demand", - "tags": [ - "Platform" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlatformProblems" + "$ref": "#/components/schemas/ReportError" } } }, "description": "" }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "" }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "" } } - } - }, - "/api/v1/admin/platform/restores": { - "get": { - "operationId": "api_v1_admin_platform_restores_list", - "summary": "List deployment restore operations", + }, + "delete": { + "operationId": "api_v1_admin_makerspaces_saved_report_views_destroy", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Backup" + "Reports" ], "security": [ { @@ -42982,98 +43362,111 @@ } ], "responses": { - "200": { + "204": { + "description": "No response body" + }, + "400": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RestoreOperation" - } + "$ref": "#/components/schemas/ReportError" } } }, "description": "" }, "401": { - "description": "Authentication is required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "" }, "403": { - "description": "The authenticated actor is not authorized." - } - } - }, - "post": { - "operationId": "api_v1_admin_platform_restores_create", - "summary": "Record restore intent for the privileged host supervisor", - "tags": [ - "Backup" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestoreCreate" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/RestoreCreate" + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/RestoreCreate" - } - } + "description": "" }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "202": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RestoreOperation" + "$ref": "#/components/schemas/ReportError" } } }, "description": "" }, - "400": { - "description": "The request is invalid for the current lifecycle state." - }, - "401": { - "description": "Authentication is required." - }, - "403": { - "description": "The authenticated actor is not authorized." + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/platform/restores/{restore_id}": { + "/api/v1/admin/makerspaces/{makerspace_id}/saved-views": { "get": { - "operationId": "api_v1_admin_platform_restores_retrieve", - "summary": "Get restore stage, diff, and decision deadline", + "operationId": "api_v1_admin_makerspaces_saved_views_retrieve", "parameters": [ { - "in": "path", - "name": "restore_id", + "in": "query", + "name": "entity", "schema": { "type": "string", - "format": "uuid" + "enum": [ + "audit", + "bookings", + "events", + "inventory", + "members", + "payments", + "requests" + ] + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } } ], "tags": [ - "Backup" + "Staff lists" ], "security": [ { @@ -43085,148 +43478,108 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RestoreOperation" + "$ref": "#/components/schemas/SavedViewList" } } }, "description": "" }, - "404": { - "description": "The requested resource does not exist in the actor's scope." + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "401": { - "description": "Authentication is required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "The authenticated actor is not authorized." - } - } - } - }, - "/api/v1/admin/platform/restores/{restore_id}/decision": { - "post": { - "operationId": "api_v1_admin_platform_restores_decision_create", - "summary": "Decide a quiesced in-place restore", - "parameters": [ - { - "in": "path", - "name": "restore_id", - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true - } - ], - "tags": [ - "Backup" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestoreDecision" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/RestoreDecision" + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/RestoreDecision" - } - } + "description": "" }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RestoreOperation" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "400": { - "description": "The request is invalid for the current lifecycle state." - }, - "404": { - "description": "The requested resource does not exist in the actor's scope." - }, - "401": { - "description": "Authentication is required." - }, - "403": { - "description": "The authenticated actor is not authorized." - } - } - } - }, - "/api/v1/admin/platform/social-auth-settings": { - "get": { - "operationId": "api_v1_admin_platform_social_auth_settings_retrieve", - "summary": "Retrieve or update platform social auth settings", - "tags": [ - "Platform" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlatformSocialAuthSettings" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." } } }, - "patch": { - "operationId": "api_v1_admin_platform_social_auth_settings_partial_update", - "summary": "Retrieve or update platform social auth settings", + "post": { + "operationId": "api_v1_admin_makerspaces_saved_views_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Platform" + "Staff lists" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedPlatformSocialAuthSettings" + "$ref": "#/components/schemas/SavedView" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedPlatformSocialAuthSettings" + "$ref": "#/components/schemas/SavedView" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedPlatformSocialAuthSettings" + "$ref": "#/components/schemas/SavedView" } } - } + }, + "required": true }, "security": [ { @@ -43234,110 +43587,102 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlatformSocialAuthSettings" + "$ref": "#/components/schemas/SavedView" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - } - } - } - }, - "/api/v1/admin/platform/tenant-migrations/deployment-identity": { - "get": { - "operationId": "api_v1_admin_platform_tenant_migrations_deployment_identity_retrieve", - "summary": "Read this deployment's signing identity and target age recipient", - "tags": [ - "Tenant migration" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeploymentIdentity" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "409": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "State conflict." + "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Superadmin access required." + "description": "" }, - "429": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Throttle limit exceeded." + "description": "" }, - "500": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected migration failure." + "description": "" } } } }, - "/api/v1/admin/platform/tenant-migrations/imports": { + "/api/v1/admin/makerspaces/{makerspace_id}/saved-views/{id}": { "get": { - "operationId": "api_v1_admin_platform_tenant_migrations_imports_list", - "summary": "List tenant import jobs", + "operationId": "api_v1_admin_makerspaces_saved_views_retrieve_2", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Tenant migration" + "Staff lists" ], "security": [ { @@ -43349,10 +43694,17 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ImportJob" - } + "$ref": "#/components/schemas/SavedView" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -43362,69 +43714,95 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Superadmin access required." + "description": "" }, - "429": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Throttle limit exceeded." + "description": "" }, - "500": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected migration failure." + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } }, - "post": { - "operationId": "api_v1_admin_platform_tenant_migrations_imports_create", - "summary": "Create an import job from an age-encrypted archive upload", + "patch": { + "operationId": "api_v1_admin_makerspaces_saved_views_partial_update", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Tenant migration" + "Staff lists" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImportCreate" + "$ref": "#/components/schemas/PatchedSavedView" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ImportCreate" + "$ref": "#/components/schemas/PatchedSavedView" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ImportCreate" + "$ref": "#/components/schemas/PatchedSavedView" } } - }, - "required": true + } }, "security": [ { @@ -43432,11 +43810,11 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImportJob" + "$ref": "#/components/schemas/SavedView" } } }, @@ -43446,82 +43824,86 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FieldValidationError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Field-keyed validation errors." + "description": "" }, - "409": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "State conflict." + "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Superadmin access required." + "description": "" }, - "429": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Throttle limit exceeded." + "description": "" }, - "500": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected migration failure." + "description": "" } } - } - }, - "/api/v1/admin/platform/tenant-migrations/imports/{job_id}": { - "get": { - "operationId": "api_v1_admin_platform_tenant_migrations_imports_retrieve", - "summary": "Read a tenant import job", + }, + "delete": { + "operationId": "api_v1_admin_makerspaces_saved_views_destroy", "parameters": [ { "in": "path", - "name": "job_id", + "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" }, "required": true } ], "tags": [ - "Tenant migration" + "Staff lists" ], "security": [ { @@ -43529,86 +43911,112 @@ } ], "responses": { - "200": { + "204": { + "description": "No response body" + }, + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImportJob" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "404": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Not found." + "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Superadmin access required." + "description": "" }, - "429": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Throttle limit exceeded." + "description": "" }, - "500": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected migration failure." + "description": "" } } } }, - "/api/v1/admin/platform/tenant-migrations/imports/{job_id}/identity-decisions": { + "/api/v1/admin/makerspaces/{makerspace_id}/spaces/": { "get": { - "operationId": "api_v1_admin_platform_tenant_migrations_imports_identity_decisions_list", - "summary": "Read the exact archived identity decision list", + "operationId": "api_v1_admin_makerspaces_spaces_retrieve", + "summary": "List bookable spaces in a makerspace", "parameters": [ { "in": "path", - "name": "job_id", + "name": "makerspace_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "ordering", + "schema": { + "type": "string" + }, + "description": "Allowlisted sort field; prefix with '-' for descending." + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + }, + "description": "Page number, 24 rows per page." + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Search within the authorized list." } ], "tags": [ - "Tenant migration" + "Admin bookings" ], "security": [ { @@ -43620,30 +44028,27 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ClosureIdentity" - } + "$ref": "#/components/schemas/BookableSpaceListResponse" } } }, "description": "" }, - "404": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Not found." + "description": "Invalid request." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -43653,66 +44058,55 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Superadmin access required." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Throttle limit exceeded." + "description": "Permission denied." }, - "500": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected migration failure." + "description": "Not found." } } }, "post": { - "operationId": "api_v1_admin_platform_tenant_migrations_imports_identity_decisions_create", - "summary": "Submit all per-person import identity decisions", + "operationId": "api_v1_admin_makerspaces_spaces_create", + "summary": "Create a bookable space", "parameters": [ { "in": "path", - "name": "job_id", + "name": "makerspace_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Tenant migration" + "Admin bookings" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImportDecisionList" + "$ref": "#/components/schemas/BookableSpaceWrite" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ImportDecisionList" + "$ref": "#/components/schemas/BookableSpaceWrite" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ImportDecisionList" + "$ref": "#/components/schemas/BookableSpaceWrite" } } }, @@ -43724,11 +44118,11 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImportJob" + "$ref": "#/components/schemas/BookableSpaceAdmin" } } }, @@ -43738,37 +44132,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FieldValidationError" - } - } - }, - "description": "Field-keyed validation errors." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "State conflict." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Not found." + "description": "Invalid request." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -43778,61 +44152,49 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Superadmin access required." + "description": "Permission denied." }, - "429": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Throttle limit exceeded." - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Unexpected migration failure." + "description": "Not found." } } } }, - "/api/v1/admin/platform/tenant-migrations/imports/{job_id}/pairings/{pairing_id}/abort": { - "post": { - "operationId": "api_v1_admin_platform_tenant_migrations_imports_pairings_abort_create", - "summary": "Abort an importing target and issue its signed proof", + "/api/v1/admin/makerspaces/{makerspace_id}/staff-invitations": { + "get": { + "operationId": "api_v1_admin_makerspaces_staff_invitations_list", "parameters": [ { "in": "path", - "name": "job_id", + "name": "makerspace_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true }, { - "in": "path", - "name": "pairing_id", + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", "schema": { - "type": "string", - "format": "uuid" - }, - "required": true + "type": "integer" + } } ], "tags": [ - "Tenant migration" + "Staff invitations" ], "security": [ { @@ -43844,117 +44206,264 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CutoverOutcome" + "$ref": "#/components/schemas/PaginatedStaffInvitationList" } } }, "description": "" }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." + } + } + }, + "post": { + "operationId": "api_v1_admin_makerspaces_staff_invitations_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Staff invitations" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StaffInvitationCreate" } }, - "description": "Not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/StaffInvitationCreate" } }, - "description": "State conflict." + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/StaffInvitationCreate" + } + } }, - "401": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/StaffInvitationDelivery" } } }, - "description": "Authentication required." + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Superadmin access required." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." }, "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } + "description": "Rate limit exceeded; retry after the interval in Retry-After." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." + }, + "503": { + "description": "A required dependency is unavailable." + } + } + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/staff-invitations/{invitation_id}/resend": { + "post": { + "operationId": "api_v1_admin_makerspaces_staff_invitations_resend_create", + "parameters": [ + { + "in": "path", + "name": "invitation_id", + "schema": { + "type": "integer" }, - "description": "Throttle limit exceeded." + "required": true }, - "500": { + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Staff invitations" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/StaffInvitationDelivery" } } }, - "description": "Unexpected migration failure." + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." + }, + "503": { + "description": "A required dependency is unavailable." } } } }, - "/api/v1/admin/platform/tenant-migrations/imports/{job_id}/pairings/{pairing_id}/activate": { + "/api/v1/admin/makerspaces/{makerspace_id}/staff-invitations/{invitation_id}/revoke": { "post": { - "operationId": "api_v1_admin_platform_tenant_migrations_imports_pairings_activate_create", - "summary": "Activate an imported target with the source receipt", + "operationId": "api_v1_admin_makerspaces_staff_invitations_revoke_create", "parameters": [ { "in": "path", - "name": "job_id", + "name": "invitation_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true }, { "in": "path", - "name": "pairing_id", + "name": "makerspace_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Tenant migration" + "Staff invitations" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StaffInvitation" + } + } + }, + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." + }, + "503": { + "description": "A required dependency is unavailable." + } + } + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/uploads/evidence-url": { + "post": { + "operationId": "api_v1_admin_makerspaces_uploads_evidence_url_create", + "description": "Base for ALL staff endpoints: authenticated + active staff + auto-scoped queryset.\n\nFuture phases subclass this so the invariant 'every staff query is makerspace-scoped'\nis enforced by default rather than by remembering to add a mixin (review fix #4). Add\n`required_action` + `HasMakerspaceAction` to a subclass for per-action checks.", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "api" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CutoverReceiptRequest" + "$ref": "#/components/schemas/EvidenceUrlRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/CutoverReceiptRequest" + "$ref": "#/components/schemas/EvidenceUrlRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/CutoverReceiptRequest" + "$ref": "#/components/schemas/EvidenceUrlRequest" } } }, @@ -43965,12 +44474,57 @@ "jwtAuth": [] } ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EvidenceUrlResponse" + } + } + }, + "description": "" + }, + "400": { + "description": "Invalid evidence upload request." + }, + "403": { + "description": "Insufficient makerspace permission." + }, + "503": { + "description": "Evidence storage is unavailable." + } + } + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/waiver": { + "get": { + "operationId": "api_v1_admin_makerspaces_waiver_retrieve", + "description": "Requires manage_members in this makerspace; staff role administration and waiver publishing require manage_makerspace.", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CutoverOutcome" + "$ref": "#/components/schemas/MemberWaiverResponse" } } }, @@ -43980,108 +44534,105 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FieldValidationError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Field-keyed validation errors." + "description": "" }, - "404": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Not found." + "description": "" }, - "409": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "State conflict." + "description": "" }, - "401": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, - "403": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Superadmin access required." + "description": "" }, - "429": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Throttle limit exceeded." + "description": "" }, - "500": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected migration failure." + "description": "" } } - } - }, - "/api/v1/admin/platform/tenant-migrations/imports/{job_id}/run": { - "post": { - "operationId": "api_v1_admin_platform_tenant_migrations_imports_run_create", - "summary": "Run an identity-decided tenant import", + }, + "put": { + "operationId": "api_v1_admin_makerspaces_waiver_update", + "description": "Requires manage_makerspace in this makerspace to publish or clear waiver text.", "parameters": [ { "in": "path", - "name": "job_id", + "name": "makerspace_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Tenant migration" + "Admin memberships" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImportRun" + "$ref": "#/components/schemas/WaiverPublish" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ImportRun" + "$ref": "#/components/schemas/WaiverPublish" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ImportRun" + "$ref": "#/components/schemas/WaiverPublish" } } } @@ -44092,11 +44643,11 @@ } ], "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImportJob" + "$ref": "#/components/schemas/MemberWaiverResponse" } } }, @@ -44106,95 +44657,91 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FieldValidationError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Field-keyed validation errors." + "description": "" }, - "409": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "State conflict." + "description": "" }, - "404": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Not found." - }, - "503": { - "description": "Import worker unavailable." + "description": "" }, - "401": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, - "403": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Superadmin access required." + "description": "" }, - "429": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Throttle limit exceeded." + "description": "" }, - "500": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected migration failure." + "description": "" } } } }, - "/api/v1/admin/platform/tenant-migrations/imports/{job_id}/verification": { + "/api/v1/admin/makerspaces/{makerspace_id}/walk-in-members": { "get": { - "operationId": "api_v1_admin_platform_tenant_migrations_imports_verification_retrieve", - "summary": "Read the import verification report", + "operationId": "api_v1_admin_makerspaces_walk_in_members_retrieve", + "summary": "Read walk-in admission questions", "parameters": [ { "in": "path", - "name": "job_id", + "name": "makerspace_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Tenant migration" + "Admin memberships" ], "security": [ { @@ -44206,164 +44753,115 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerificationReport" + "$ref": "#/components/schemas/PublicAdmission" } } }, "description": "" }, - "404": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Not found." + "description": "" }, - "409": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "State conflict." + "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Superadmin access required." + "description": "" }, - "429": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Throttle limit exceeded." + "description": "" }, - "500": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected migration failure." - } - } - } - }, - "/api/v1/admin/platform/tenant-migrations/pairings": { - "get": { - "operationId": "api_v1_admin_platform_tenant_migrations_pairings_list", - "summary": "List pinned migration pairings", - "tags": [ - "Tenant migration" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "" + }, + "503": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Pairing" - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Superadmin access required." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Throttle limit exceeded." - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Unexpected migration failure." } } }, "post": { - "operationId": "api_v1_admin_platform_tenant_migrations_pairings_create", - "summary": "Approve and pin source/target deployment identities", + "operationId": "api_v1_admin_makerspaces_walk_in_members_create", + "summary": "Create a walk-in member record", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Tenant migration" + "Admin memberships" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PairingCreate" + "$ref": "#/components/schemas/WalkInMemberCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PairingCreate" + "$ref": "#/components/schemas/WalkInMemberCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PairingCreate" + "$ref": "#/components/schemas/WalkInMemberCreate" } } }, @@ -44379,7 +44877,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Pairing" + "$ref": "#/components/schemas/WalkInAdmissionResult" } } }, @@ -44389,81 +44887,90 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FieldValidationError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Field-keyed validation errors." + "description": "" }, - "409": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "State conflict." + "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Superadmin access required." + "description": "" }, - "429": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Throttle limit exceeded." + "description": "" }, - "500": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected migration failure." + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/printer-jobs/{id}": { + "/api/v1/admin/makerspaces/{makerspace_id}/website-embed-origins/": { "get": { - "operationId": "api_v1_admin_printer_jobs_retrieve", + "operationId": "api_v1_admin_makerspaces_website_embed_origins_retrieve", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Devices" + "Public websites" ], "security": [ { @@ -44475,101 +44982,154 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Job" + "$ref": "#/components/schemas/WebsiteOrigins" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid payload or adapter module disabled." + "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication/signature/replay refused." + "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authority or device revoked." + "description": "" + } + } + }, + "put": { + "operationId": "api_v1_admin_makerspaces_website_embed_origins_update", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Public websites" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebsiteOrigins" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/WebsiteOrigins" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/WebsiteOrigins" + } + } }, - "404": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/WebsiteOrigins" } } }, - "description": "Target outside scope or absent." + "description": "" }, - "409": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Conflicting command/report." + "description": "" }, - "410": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Delivery lease expired." + "description": "" }, - "423": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant frozen for migration." + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/printer-jobs/{id}/apply": { - "post": { - "operationId": "api_v1_admin_printer_jobs_apply_create", + "/api/v1/admin/makerspaces/{id}": { + "get": { + "operationId": "api_v1_admin_makerspaces_retrieve", + "summary": "Retrieve a makerspace's settings", "parameters": [ { "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Devices" + "Admin makerspaces" ], "security": [ { @@ -44581,89 +45141,209 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Job" + "$ref": "#/components/schemas/Makerspace" } } }, "description": "" }, - "400": { + "401": { + "description": "Authentication required." + }, + "404": { + "description": "Makerspace not found, archived, not active, hidden from superadmins, or outside the actor's scope for this method." + } + } + }, + "patch": { + "operationId": "api_v1_admin_makerspaces_partial_update", + "description": "Needs manage_makerspace. `enabled_modules` is superadmin-owned and edited only in /control/ or the module console: a body carrying it is refused with 403.", + "summary": "Update a makerspace's settings", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin makerspaces" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedMakerspace" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedMakerspace" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedMakerspace" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/Makerspace" } } }, - "description": "Invalid payload or adapter module disabled." + "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "type": "object", + "additionalProperties": {}, + "properties": { + "theme_config": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "required": [ + "code", + "messages" + ], + "properties": { + "code": { + "type": "string", + "enum": [ + "theme_contrast" + ] + }, + "messages": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + } + } } } }, - "description": "Authentication/signature/replay refused." + "description": "Field validation errors. Contrast errors have theme_config: {code: 'theme_contrast', messages: string[]} with mode, failing pair, measured ratio and required ratio." + }, + "401": { + "description": "Authentication required." }, "403": { + "description": "The body carries `enabled_modules`." + }, + "404": { + "description": "Makerspace not found, archived, not active, hidden from superadmins, or outside the actor's scope for this method." + } + } + } + }, + "/api/v1/admin/member-cards/{id}/print.pdf": { + "post": { + "operationId": "api_v1_admin_member_cards_print.pdf_create", + "summary": "Print one card (CR80 PDF)", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { - "application/json": { + "application/pdf": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "type": "string", + "format": "binary" } } }, - "description": "Authority or device revoked." + "description": "" }, - "404": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Target outside scope or absent." + "description": "Invalid card input or membership module disabled." }, - "409": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Conflicting command/report." + "description": "Authentication required." }, - "410": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Delivery lease expired." + "description": "Missing member-card action or inactive account." }, - "423": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant frozen for migration." + "description": "Not found in the permitted makerspace." } } } }, - "/api/v1/admin/products/{id}/assets/generate": { + "/api/v1/admin/member-cards/{id}/reissue": { "post": { - "operationId": "api_v1_admin_products_assets_generate_create", - "summary": "Generate asset units", + "operationId": "api_v1_admin_member_cards_reissue_create", + "summary": "Reissue (rotate the QR of) a card", "parameters": [ { "in": "path", @@ -44675,23 +45355,23 @@ } ], "tags": [ - "Asset units" + "Admin memberships" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetGenerate" + "$ref": "#/components/schemas/MemberCardReissue" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/AssetGenerate" + "$ref": "#/components/schemas/MemberCardReissue" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/AssetGenerate" + "$ref": "#/components/schemas/MemberCardReissue" } } }, @@ -44703,11 +45383,11 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetGenerateResult" + "$ref": "#/components/schemas/MemberCard" } } }, @@ -44717,39 +45397,17 @@ "content": { "application/json": { "schema": { - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid input, disabled module, or non-draft print batch." + "description": "Invalid card input or membership module disabled." }, "401": { "content": { "application/json": { "schema": { - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -44759,72 +45417,29 @@ "content": { "application/json": { "schema": { - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "QR management permission required." + "description": "Missing member-card action or inactive account." }, "404": { "content": { "application/json": { "schema": { - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } - } - }, - "description": "Batch or inventory target not in scope." - }, - "423": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant frozen for migration." + "description": "Not found in the permitted makerspace." } } } }, - "/api/v1/admin/qr-print-batches/{id}": { - "get": { - "operationId": "api_v1_admin_qr_print_batches_retrieve", - "summary": "Retrieve QR print batch", + "/api/v1/admin/member-cards/{id}/revoke": { + "post": { + "operationId": "api_v1_admin_member_cards_revoke_create", + "summary": "Revoke a card (redacts name and photo)", "parameters": [ { "in": "path", @@ -44836,8 +45451,27 @@ } ], "tags": [ - "QR print batches" + "Admin memberships" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberCardRevoke" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MemberCardRevoke" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MemberCardRevoke" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -44848,56 +45482,60 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QrPrintBatchDetail" + "$ref": "#/components/schemas/MemberCard" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/qr-print-batches/{id}/download": { - "get": { - "operationId": "api_v1_admin_qr_print_batches_download_retrieve", - "summary": "Download QR print batch as a ZIP of captioned SVGs", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true - } - ], - "tags": [ - "QR print batches" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "Invalid card input or membership module disabled." + }, + "401": { "content": { - "application/zip": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Missing member-card action or inactive account." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found in the permitted makerspace." } } } }, - "/api/v1/admin/qr-print-batches/{id}/items": { - "post": { - "operationId": "api_v1_admin_qr_print_batches_items_create", - "summary": "Add QR code to draft print batch", + "/api/v1/admin/membership-plans/{id}": { + "patch": { + "operationId": "api_v1_admin_membership_plans_partial_update", + "description": "Requires manage_makerspace in this makerspace; manage_members alone cannot change plan definitions or prices.", + "summary": "Update a membership plan", "parameters": [ { "in": "path", @@ -44909,27 +45547,26 @@ } ], "tags": [ - "QR print batches" + "Admin memberships" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QrPrintBatchItemCreate" + "$ref": "#/components/schemas/PatchedMembershipPlan" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/QrPrintBatchItemCreate" + "$ref": "#/components/schemas/PatchedMembershipPlan" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/QrPrintBatchItemCreate" + "$ref": "#/components/schemas/PatchedMembershipPlan" } } - }, - "required": true + } }, "security": [ { @@ -44937,11 +45574,11 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QrPrintBatchItemResult" + "$ref": "#/components/schemas/MembershipPlan" } } }, @@ -44951,135 +45588,94 @@ "content": { "application/json": { "schema": { - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid input, disabled module, or non-draft print batch." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "QR management permission required." + "description": "" }, "404": { "content": { "application/json": { "schema": { - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Batch or inventory target not in scope." + "description": "" }, - "423": { + "409": { "content": { "application/json": { "schema": { - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant frozen for migration." + "description": "" } } } }, - "/api/v1/admin/qr-print-batches/{id}/label-commands": { + "/api/v1/admin/membership-requests": { "get": { - "operationId": "api_v1_admin_qr_print_batches_label_commands_list", - "summary": "List label commands for a QR batch", + "operationId": "api_v1_admin_membership_requests_retrieve", + "description": "Requires manage_members in this makerspace; staff role administration and waiver publishing require manage_makerspace.", "parameters": [ { - "in": "path", - "name": "id", + "in": "query", + "name": "makerspace_id", "schema": { "type": "integer" }, "required": true }, { - "name": "page", - "required": false, "in": "query", - "description": "A page number within the paginated result set.", + "name": "ordering", + "schema": { + "type": "string" + }, + "description": "Allowlisted sort field; prefix with '-' for descending." + }, + { + "in": "query", + "name": "page", "schema": { "type": "integer" + }, + "description": "Page number, 24 rows per page." + }, + { + "in": "query", + "name": "state", + "schema": { + "type": "string" } } ], "tags": [ - "Devices" + "Admin memberships" ], "security": [ { @@ -45091,7 +45687,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedLabelCommandList" + "$ref": "#/components/schemas/MembershipRequestListResponse" } } }, @@ -45101,88 +45697,79 @@ "content": { "application/json": { "schema": { - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "DRF field/module validation errors or non-field validation messages." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required or invalid signature." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Command or batch not in scope." + "description": "" }, "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Idempotency or delivery conflict." + "description": "" }, - "410": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Batch no longer available." + "description": "" }, - "423": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant frozen for migration." + "description": "" } } - }, + } + }, + "/api/v1/admin/membership-requests/{id}/approve": { "post": { - "operationId": "api_v1_admin_qr_print_batches_label_commands_create", - "summary": "Send or reprint a QR batch", + "operationId": "api_v1_admin_membership_requests_approve_create", + "description": "Requires manage_members. Returns an admission request when payment is due, otherwise the activated membership.", "parameters": [ { "in": "path", @@ -45194,23 +45781,23 @@ } ], "tags": [ - "Devices" + "Admin memberships" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LabelCreate" + "$ref": "#/components/schemas/ApproveRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/LabelCreate" + "$ref": "#/components/schemas/ApproveRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/LabelCreate" + "$ref": "#/components/schemas/ApproveRequest" } } }, @@ -45226,17 +45813,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LabelCommand" - } - } - }, - "description": "" - }, - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LabelCommand" + "$ref": "#/components/schemas/MembershipApprovalOutcome" } } }, @@ -45246,119 +45823,79 @@ "content": { "application/json": { "schema": { - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "DRF field/module validation errors or non-field validation messages." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required or invalid signature." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Command or batch not in scope." + "description": "" }, "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Idempotency or delivery conflict." + "description": "" }, - "410": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Batch no longer available." + "description": "" }, - "423": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant frozen for migration." - } - } - } - }, - "/api/v1/admin/qr/{id}/print": { - "get": { - "operationId": "api_v1_admin_qr_print_retrieve", - "summary": "Render QR label SVG", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "QR assets" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "description": "SVG QR label." + "description": "" } } } }, - "/api/v1/admin/qr/{id}/rebind-target": { + "/api/v1/admin/membership-requests/{id}/revoke": { "post": { - "operationId": "api_v1_admin_qr_rebind_target_create", - "summary": "Rebind a saved QR to another product/asset (cross-makerspace = superadmin) and optionally rename", + "operationId": "api_v1_admin_membership_requests_revoke_create", + "description": "Requires manage_members in this makerspace; staff role administration and waiver publishing require manage_makerspace.", "parameters": [ { "in": "path", @@ -45370,27 +45907,26 @@ } ], "tags": [ - "QR assets" + "Admin memberships" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QrRebindTarget" + "$ref": "#/components/schemas/Revoke" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/QrRebindTarget" + "$ref": "#/components/schemas/Revoke" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/QrRebindTarget" + "$ref": "#/components/schemas/Revoke" } } - }, - "required": true + } }, "security": [ { @@ -45402,22 +45938,90 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QrRebindResult" + "$ref": "#/components/schemas/MembershipRequest" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, "409": { - "description": "Outstanding loan or target QR conflict." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/qr/{id}/revoke": { + "/api/v1/admin/membership-terms/{id}/cancel": { "post": { - "operationId": "api_v1_admin_qr_revoke_create", - "summary": "Revoke active QR code", + "operationId": "api_v1_admin_membership_terms_cancel_create", + "description": "Requires manage_members in this makerspace. Staff role administration and membership plan definitions require manage_makerspace.", + "summary": "Cancel an active term", "parameters": [ { "in": "path", @@ -45429,7 +46033,7 @@ } ], "tags": [ - "QR assets" + "Admin memberships" ], "security": [ { @@ -45441,264 +46045,202 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QrCode" + "$ref": "#/components/schemas/MembershipTerm" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/qr/boxes": { - "post": { - "operationId": "api_v1_admin_qr_boxes_create", - "summary": "Create a QR-coded box", - "tags": [ - "QR assets" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateBoxQr" - }, - "examples": { - "CreateAQR-codedBox": { - "value": { - "makerspace_id": 1, - "label": "Electronics Box A", - "location": "Bench Storage", - "description": "Issued hardware kit box" - }, - "summary": "Create a QR-coded box" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/CreateBoxQr" + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/CreateBoxQr" + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } - } + }, + "description": "" }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "201": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Box" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/qr/containers": { - "post": { - "operationId": "api_v1_admin_qr_containers_create", - "summary": "Create a QR-coded box", - "tags": [ - "QR assets" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateBoxQr" - }, - "examples": { - "CreateAQR-codedBox": { - "value": { - "makerspace_id": 1, - "label": "Electronics Box A", - "location": "Bench Storage", - "description": "Issued hardware kit box" - }, - "summary": "Create a QR-coded box" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/CreateBoxQr" - } + "description": "" + } + } + } + }, + "/api/v1/admin/memberships": { + "get": { + "operationId": "api_v1_admin_memberships_retrieve", + "description": "Requires manage_members in this makerspace; staff role administration and waiver publishing require manage_makerspace.", + "parameters": [ + { + "in": "query", + "name": "id", + "schema": { + "type": "integer" }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/CreateBoxQr" - } - } + "description": "Select one membership within the authorized roster." }, - "required": true - }, + { + "in": "query", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "query", + "name": "ordering", + "schema": { + "type": "string" + }, + "description": "Allowlisted sort field; prefix with '-' for descending." + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + }, + "description": "Page number, 24 rows per page." + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Search within the authorized list." + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string" + } + } + ], + "tags": [ + "Admin memberships" + ], "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Box" + "$ref": "#/components/schemas/MembershipListResponse" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/qr/resolve": { - "post": { - "operationId": "api_v1_admin_qr_resolve_create", - "description": "Resolve the opaque payload encoded in a physical QR label to its target (box, product, or asset) and the scanner actions the caller may take. `payload` is the raw 32-char lowercase hex token printed on the label (Python `uuid4().hex`, e.g. `3f9a1c2b4d5e6f7081920a1b2c3d4e5f`) - it is the value stored as `QrCode.payload` (and `Box.code` for boxes), not a URL or JSON. Resolving a QR also records an immutable scanner-lookup scan event.", - "summary": "Resolve QR target and scanner allowed actions", - "tags": [ - "QR assets" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/QrResolve" - }, - "examples": { - "ResolveAScannedQRPayload": { - "value": { - "payload": "3f9a1c2b4d5e6f7081920a1b2c3d4e5f" - }, - "summary": "Resolve a scanned QR payload" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/QrResolve" + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/QrResolve" - } - } + "description": "" }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QrResolveResult" - }, - "examples": { - "ResolvedBoxQR": { - "value": { - "qr": { - "id": 12, - "makerspace": 1, - "payload": "3f9a1c2b4d5e6f7081920a1b2c3d4e5f", - "target_type": "box", - "target_id": 5, - "status": "active", - "created_at": "2026-06-27T09:30:00Z", - "updated_at": "2026-06-27T09:30:00Z", - "revoked_at": null - }, - "target": { - "type": "box", - "id": 5, - "label": "Electronics Box A", - "code": "3f9a1c2b4d5e6f7081920a1b2c3d4e5f" - }, - "allowed_actions": [ - "contents", - "move_container", - "record_scan", - "revoke", - "view" - ] - }, - "summary": "Resolved box QR" - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/qr/scan": { - "post": { - "operationId": "api_v1_admin_qr_scan_create", - "description": "Context is limited to issue or return and scan events are immutable.", - "summary": "Record a QR scan", - "tags": [ - "QR assets" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/QrScan" - }, - "examples": { - "ScanQRDuringIssue": { - "value": { - "payload": "3f9a1c2b4d5e6f7081920a1b2c3d4e5f", - "context": "issue", - "request_id": 99 - }, - "summary": "Scan QR during issue" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/QrScan" + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/QrScan" + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } - } + }, + "description": "" }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "201": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QrScanResult" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -45707,56 +46249,46 @@ } } }, - "/api/v1/admin/qr/tools": { - "post": { - "operationId": "api_v1_admin_qr_tools_create", - "summary": "Create or reuse a QR code for a product or asset", + "/api/v1/admin/memberships/{id}": { + "delete": { + "operationId": "api_v1_admin_memberships_destroy", + "description": "Remove a single makerspace membership (un-assign a delegable role).\n\nScope contract (mirrors the create path's non-escalation model): a Space Manager may\nrevoke ONLY delegable-role memberships within their MANAGE_MAKERSPACE scope; a superadmin\nmay revoke any, except inside a superadmin-hidden makerspace (governance hard-block ->\n404). 404-before-403: out-of-scope existence is hidden as 404, a delegable-scope actor\naiming at a SPACE_MANAGER gets 403.", + "summary": "Revoke a makerspace staff membership", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "QR assets" + "Admin users" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateToolQr" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/CreateToolQr" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/CreateToolQr" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/QrCode" - } - } - }, - "description": "" + "204": { + "description": "Membership revoked." + }, + "403": { + "description": "A Space Manager membership can only be revoked by a superadmin." + }, + "404": { + "description": "Membership not found or outside the actor's makerspace scope." } } } }, - "/api/v1/admin/report-schedules/{id}": { + "/api/v1/admin/memberships/{id}/capabilities": { "patch": { - "operationId": "api_v1_admin_report_schedules_partial_update", - "summary": "Update report schedule", + "operationId": "api_v1_admin_memberships_capabilities_partial_update", + "description": "Requires manage_members in this makerspace. Staff role administration and membership plan definitions require manage_makerspace.", "parameters": [ { "in": "path", @@ -45768,23 +46300,23 @@ } ], "tags": [ - "Reports" + "Admin memberships" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedReportSchedule" + "$ref": "#/components/schemas/PatchedMembershipCapabilities" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedReportSchedule" + "$ref": "#/components/schemas/PatchedMembershipCapabilities" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedReportSchedule" + "$ref": "#/components/schemas/PatchedMembershipCapabilities" } } } @@ -45799,7 +46331,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportSchedule" + "$ref": "#/components/schemas/AdminMembership" } } }, @@ -45809,67 +46341,59 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid schedule or module disabled." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Makerspace or schedule not found." - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant writes are frozen." + "description": "" }, - "503": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant write gate unavailable." + "description": "" } } - }, - "delete": { - "operationId": "api_v1_admin_report_schedules_destroy", - "summary": "Delete report schedule", + } + }, + "/api/v1/admin/memberships/{id}/revoke": { + "post": { + "operationId": "api_v1_admin_memberships_revoke_create", + "description": "Requires manage_members in this makerspace; staff role administration and waiver publishing require manage_makerspace.", "parameters": [ { "in": "path", @@ -45881,84 +46405,120 @@ } ], "tags": [ - "Reports" + "Admin memberships" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Revoke" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Revoke" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Revoke" + } + } + } + }, "security": [ { "jwtAuth": [] } ], "responses": { - "204": { - "description": "No response body" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminMembership" + } + } + }, + "description": "" }, "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid schedule or module disabled." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace or schedule not found." + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant writes are frozen." + "description": "" }, "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant write gate unavailable." + "description": "" } } } }, - "/api/v1/admin/report-schedules/{id}/run-now": { - "post": { - "operationId": "api_v1_admin_report_schedules_run_now_create", - "summary": "Run a report schedule now", + "/api/v1/admin/memberships/{id}/role": { + "patch": { + "operationId": "api_v1_admin_memberships_role_partial_update", + "description": "Requires manage_members; staff role changes require manage_makerspace. Replace the complete role set using exactly one of role_id or role_ids. Duplicate IDs are ignored; Space Manager and Member are exclusive. Unknown or foreign IDs return 404.", "parameters": [ { "in": "path", @@ -45970,8 +46530,27 @@ } ], "tags": [ - "Reports" + "Admin memberships" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedRoleSetInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedRoleSetInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedRoleSetInput" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -45982,214 +46561,103 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportDelivery" + "$ref": "#/components/schemas/AdminMembership" } } }, "description": "" }, - "409": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "type": "object", + "additionalProperties": {} } } }, - "description": "Schedule inactive or skipped." + "description": "Field validation errors. Provide exactly one of role_id or role_ids; the set must be nonempty, and Space Manager and Member are exclusive." }, - "400": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid schedule or module disabled." + "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Permission denied." + "description": "" }, - "404": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace or schedule not found." + "description": "" }, "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant writes are frozen." + "description": "" }, "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant write gate unavailable." + "description": "" } } } }, - "/api/v1/admin/reports/{report_key}/export": { + "/api/v1/admin/memberships/{id}/terms": { "get": { - "operationId": "api_v1_admin_reports_export_retrieve", - "summary": "Export aggregate report", + "operationId": "api_v1_admin_memberships_terms_list", + "description": "Requires manage_members in this makerspace. Staff role administration and membership plan definitions require manage_makerspace.", + "summary": "List the terms of a membership", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "format", - "schema": { - "type": "string", - "enum": [ - "csv", - "xlsx" - ] - } - }, - { - "in": "query", - "name": "grain", - "schema": { - "type": "string", - "enum": [ - "day", - "month", - "week" - ] - } - }, - { - "in": "query", - "name": "months", - "schema": { - "type": "integer" - }, - "description": "Retention anniversary in months (1-120); default 3, 6 and 12." - }, { "in": "path", - "name": "report_key", + "name": "id", "schema": { - "type": "string", - "enum": [ - "active-loans", - "booking-utilization", - "cash-book", - "certification-coverage", - "communications-health", - "community-engagement", - "damaged-lost", - "damaged-missing", - "event-attendance", - "evidence-compliance", - "fablab-health", - "import-quality", - "inventory-control", - "loan-throughput", - "machine-service", - "machine-usage", - "maintenance-activity", - "member-activity", - "member-retention", - "membership-revenue", - "module-operational-health", - "most-lent", - "payment-cash-book", - "payment-reconciliation", - "printer-service", - "procurement-performance", - "qr-scans", - "recently-added", - "returns", - "revenue-over-time", - "summary", - "taken-items", - "top-borrowers" - ] + "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "loan_deposit", - "loan_late_fee", - "machine_service_request", - "makerspace_membership", - "membership_request", - "membership_term" - ] - } } ], "tags": [ - "Reports" + "Admin memberships" ], "security": [ { @@ -46199,15 +46667,12 @@ "responses": { "200": { "content": { - "text/csv": { - "schema": { - "type": "string" - } - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "type": "array", + "items": { + "$ref": "#/components/schemas/MembershipTerm" + } } } }, @@ -46217,115 +46682,58 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid report request." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Makerspace or report not found." - } - } - } - }, - "/api/v1/admin/reports/catalog": { - "get": { - "operationId": "api_v1_admin_reports_catalog_retrieve", - "summary": "List deployment report catalog", - "tags": [ - "Reports" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportCatalog" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Invalid report request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Permission denied." - }, - "404": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace or report not found." + "description": "" } } - } - }, - "/api/v1/admin/requests/{id}/accept": { + }, "post": { - "operationId": "api_v1_admin_requests_accept_create", - "summary": "Accept borrow request", + "operationId": "api_v1_admin_memberships_terms_create", + "description": "Requires manage_members in this makerspace. Staff role administration and membership plan definitions require manage_makerspace.", + "summary": "Open a term for a membership", "parameters": [ { "in": "path", @@ -46337,26 +46745,27 @@ } ], "tags": [ - "Admin requests" + "Admin memberships" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AcceptRequest" + "$ref": "#/components/schemas/MembershipTermCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/AcceptRequest" + "$ref": "#/components/schemas/MembershipTermCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/AcceptRequest" + "$ref": "#/components/schemas/MembershipTermCreate" } } - } + }, + "required": true }, "security": [ { @@ -46364,11 +46773,11 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequest" + "$ref": "#/components/schemas/MembershipTerm" } } }, @@ -46382,7 +46791,7 @@ } } }, - "description": "Invalid request." + "description": "" }, "401": { "content": { @@ -46392,7 +46801,7 @@ } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { @@ -46402,7 +46811,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -46412,7 +46821,7 @@ } } }, - "description": "Not found." + "description": "" }, "409": { "content": { @@ -46422,15 +46831,15 @@ } } }, - "description": "Workflow conflict." + "description": "" } } } }, - "/api/v1/admin/requests/{id}/assign-box": { + "/api/v1/admin/memberships/{id}/unverify": { "post": { - "operationId": "api_v1_admin_requests_assign_box_create", - "summary": "Assign box to accepted request", + "operationId": "api_v1_admin_memberships_unverify_create", + "description": "Requires manage_members or an active membership with can_verify in this makerspace. Delegated verifiers cannot unverify themselves.", "parameters": [ { "in": "path", @@ -46442,28 +46851,8 @@ } ], "tags": [ - "Admin requests" + "Admin memberships" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AssignBox" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/AssignBox" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/AssignBox" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -46474,7 +46863,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequest" + "$ref": "#/components/schemas/AdminMembership" } } }, @@ -46488,7 +46877,7 @@ } } }, - "description": "Invalid request." + "description": "" }, "401": { "content": { @@ -46498,7 +46887,7 @@ } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { @@ -46508,7 +46897,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -46518,7 +46907,7 @@ } } }, - "description": "Not found." + "description": "" }, "409": { "content": { @@ -46528,15 +46917,15 @@ } } }, - "description": "Workflow conflict." + "description": "" } } } }, - "/api/v1/admin/requests/{id}/issue": { + "/api/v1/admin/memberships/{id}/verify": { "post": { - "operationId": "api_v1_admin_requests_issue_create", - "summary": "Issue accepted request", + "operationId": "api_v1_admin_memberships_verify_create", + "description": "Requires manage_members or an active membership with can_verify in this makerspace. Delegated verifiers cannot verify themselves.", "parameters": [ { "in": "path", @@ -46548,28 +46937,8 @@ } ], "tags": [ - "Admin requests" + "Admin memberships" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/IssueRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/IssueRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/IssueRequest" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -46580,7 +46949,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequest" + "$ref": "#/components/schemas/AdminMembership" } } }, @@ -46594,7 +46963,7 @@ } } }, - "description": "Invalid request." + "description": "" }, "401": { "content": { @@ -46604,7 +46973,7 @@ } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { @@ -46614,7 +46983,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -46624,7 +46993,7 @@ } } }, - "description": "Not found." + "description": "" }, "409": { "content": { @@ -46634,25 +47003,14 @@ } } }, - "description": "Workflow conflict." - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Service unavailable." + "description": "" } } } }, - "/api/v1/admin/requests/{id}/reject": { + "/api/v1/admin/memberships/{id}/waiver/witness": { "post": { - "operationId": "api_v1_admin_requests_reject_create", - "summary": "Reject borrow request", + "operationId": "api_v1_admin_memberships_waiver_witness_create", "parameters": [ { "in": "path", @@ -46664,28 +47022,8 @@ } ], "tags": [ - "Admin requests" + "Admin memberships" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RejectRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/RejectRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/RejectRequest" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -46696,7 +47034,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequest" + "$ref": "#/components/schemas/WitnessWaiverResponse" } } }, @@ -46710,7 +47048,7 @@ } } }, - "description": "Invalid request." + "description": "" }, "401": { "content": { @@ -46720,7 +47058,7 @@ } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { @@ -46730,7 +47068,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -46740,7 +47078,7 @@ } } }, - "description": "Not found." + "description": "" }, "409": { "content": { @@ -46750,15 +47088,15 @@ } } }, - "description": "Workflow conflict." + "description": "" } } } }, - "/api/v1/admin/requests/{id}/return": { - "post": { - "operationId": "api_v1_admin_requests_return_create", - "summary": "Return issued request items", + "/api/v1/admin/organization-invitations/{id}/": { + "delete": { + "operationId": "api_v1_admin_organization_invitations_destroy", + "summary": "Revoke an unused organization invitation", "parameters": [ { "in": "path", @@ -46770,53 +47108,16 @@ } ], "tags": [ - "Admin requests" + "Admin organizations" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReturnRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ReturnRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ReturnRequest" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminRequest" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid request." + "204": { + "description": "No response body" }, "401": { "content": { @@ -46826,7 +47127,7 @@ } } }, - "description": "Authentication required." + "description": "Authentication is required." }, "403": { "content": { @@ -46836,7 +47137,7 @@ } } }, - "description": "Permission denied." + "description": "Organization authority is required." }, "404": { "content": { @@ -46846,19 +47147,12 @@ } } }, - "description": "Not found." + "description": "Organization not found." }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Workflow conflict." + "400": { + "description": "Invalid organization data." }, - "503": { + "409": { "content": { "application/json": { "schema": { @@ -46866,48 +47160,36 @@ } } }, - "description": "Service unavailable." + "description": "Organization state conflict." } } } }, - "/api/v1/admin/requests/{id}/return-due": { - "post": { - "operationId": "api_v1_admin_requests_return_due_create", - "summary": "Set request return due time", + "/api/v1/admin/organizations/": { + "get": { + "operationId": "admin_organizations_list", + "summary": "List organizations visible to the actor", "parameters": [ { - "in": "path", - "name": "id", + "in": "query", + "name": "active", "schema": { - "type": "integer" + "type": "boolean" }, - "required": true + "description": "Filter by active status: true or false." + }, + { + "in": "query", + "name": "search", + "schema": { + "type": "string" + }, + "description": "Case-insensitive substring of organization name or slug." } ], "tags": [ - "Admin requests" + "Admin organizations" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReturnDue" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ReturnDue" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ReturnDue" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -46918,22 +47200,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequest" + "$ref": "#/components/schemas/OrganizationList" } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid request." - }, "401": { "content": { "application/json": { @@ -46942,7 +47214,7 @@ } } }, - "description": "Authentication required." + "description": "Authentication is required." }, "403": { "content": { @@ -46952,7 +47224,7 @@ } } }, - "description": "Permission denied." + "description": "Organization authority is required." }, "404": { "content": { @@ -46962,58 +47234,50 @@ } } }, - "description": "Not found." + "description": "Organization not found." }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Workflow conflict." + "400": { + "description": "Invalid active filter; use true or false." } } - } - }, - "/api/v1/admin/requests/{id}/timeline": { - "get": { - "operationId": "api_v1_admin_requests_timeline_retrieve", - "description": "Scans, evidence, asset outcomes, accountability, problem reports and related audit rows (`kind=audit`; metadata is the presentation-catalogue projection), ordered by (at, kind, source id). Events with an `evidence_id` carry `evidence_retention` (`null` = no retention-state row, not proof the bytes exist). No object key or signed URL is ever returned; photo bytes stay behind the UPLOAD_EVIDENCE-gated evidence endpoint.", - "summary": "Read-only immutable timeline for one hardware request", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - }, - "description": "Maximum history events to return. Defaults to 200; capped at 500." - } - ], + }, + "post": { + "operationId": "api_v1_admin_organizations_create", + "summary": "Create an organization (superadmin only)", "tags": [ - "Admin requests" + "Admin organizations" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationCreate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/OrganizationCreate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/OrganizationCreate" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RequestTimelineResponse" + "$ref": "#/components/schemas/OrganizationDetail" } } }, @@ -47023,95 +47287,237 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Invalid request." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Authentication required." + "description": "Not signed in." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Not found." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Workflow conflict." + "description": "Conflicts with the current state." } } } }, - "/api/v1/admin/search": { + "/api/v1/admin/organizations/{organization_id}/analytics/{report_key}": { "get": { - "operationId": "api_v1_admin_search_retrieve", - "description": "At most limit hits per entity (default 5, maximum 10). Members match username; bookings match space name or booking ID. Disabled or unauthorized entity lists contribute no hits.", - "summary": "Search authorized staff lists", + "operationId": "api_v1_admin_organizations_analytics_retrieve", + "description": "Requires an active organization membership carrying the report's action. Authorization and the owned-makerspace set are resolved server-side; a combined total is always returned together with its per-makerspace breakdown. A chart_spec projected onto the aggregation strategy's total fields accompanies combinable charts; null means a table-only combined projection.", + "summary": "Get organization analytics report", "parameters": [ { "in": "query", - "name": "limit", + "name": "end", "schema": { - "type": "integer", - "maximum": 10, - "minimum": 1, - "default": 5 + "type": "string", + "format": "date" } }, { "in": "query", - "name": "makerspace_id", + "name": "grain", "schema": { - "type": "integer", - "maximum": 9223372036854775807, - "minimum": 1, - "format": "int64" + "type": "string", + "enum": [ + "day", + "month", + "week" + ] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "organization_id", + "schema": { + "type": "integer" }, "required": true }, { - "in": "query", - "name": "q", + "in": "path", + "name": "report_key", "schema": { "type": "string", - "maxLength": 100, - "minLength": 2 + "enum": [ + "asset-usage", + "audit-activity", + "booking-utilization", + "cash-book", + "communications-health", + "consumable-pools", + "consumable-usage", + "damaged-lost", + "damaged-missing", + "device-health", + "event-attendance", + "event-feedback-scores", + "event-funnel", + "event-revenue", + "event-signups", + "evidence-compliance", + "fablab-health", + "inventory-control", + "inventory-utilisation", + "inventory-watch-demand", + "loan-throughput", + "machine-downtime", + "machine-errors", + "machine-service", + "machine-usage", + "maintenance-activity", + "member-activity", + "member-retention", + "membership-growth", + "membership-revenue", + "mobile-auth", + "mobile-devices", + "most-lent", + "organization-membership", + "payment-cash-book", + "payment-reconciliation", + "payments-ageing", + "presence-activity", + "printer-jobs", + "problem-reports", + "procurement-performance", + "qr-scans", + "recently-added", + "request-funnel", + "revenue-over-time", + "stock-health", + "stock-transfers", + "stocktake-variance", + "summary", + "taken-items", + "warranty-expiry" + ] }, "required": true + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" + ] + } } ], "tags": [ - "Staff lists" + "Analytics" ], "security": [ { @@ -47123,7 +47529,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SearchOutput" + "$ref": "#/components/schemas/OrganizationReportResponse" } } }, @@ -47133,71 +47539,61 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Invalid or excluded organization report." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Organization or report not found." } } } }, - "/api/v1/admin/space-pack-profiles": { + "/api/v1/admin/organizations/{id}/": { "get": { - "operationId": "api_v1_admin_space_pack_profiles_list", + "operationId": "admin_organizations_retrieve", + "summary": "Retrieve organization governance details", "parameters": [ { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", + "in": "path", + "name": "id", "schema": { "type": "integer" - } + }, + "required": true } ], "tags": [ - "Platform" + "Admin organizations" ], "security": [ { @@ -47209,17 +47605,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedPackProfileList" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PackError" + "$ref": "#/components/schemas/OrganizationDetail" } } }, @@ -47229,88 +47615,68 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Authentication is required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Organization authority is required." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackError" - } - } - }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PackError" - } - } - }, - "description": "" - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PackError" - } - } - }, - "description": "" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PackError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Organization not found." } } }, - "post": { - "operationId": "api_v1_admin_space_pack_profiles_create", + "patch": { + "operationId": "api_v1_admin_organizations_partial_update", + "summary": "Update an organization public profile", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Platform" + "Admin organizations" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackProfile" + "$ref": "#/components/schemas/PatchedOrganizationProfileUpdate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PackProfile" + "$ref": "#/components/schemas/PatchedOrganizationProfileUpdate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PackProfile" + "$ref": "#/components/schemas/PatchedOrganizationProfileUpdate" } } - }, - "required": true + } }, "security": [ { @@ -47318,93 +47684,136 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackProfile" + "$ref": "#/components/schemas/OrganizationDetail" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Authentication is required." }, - "401": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Organization authority is required." }, - "403": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Organization not found." }, - "404": { + "400": { + "description": "Invalid organization data." + }, + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Organization state conflict." + } + } + }, + "delete": { + "operationId": "api_v1_admin_organizations_destroy", + "summary": "Delete an unlinked organization after recent re-authentication", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin organizations" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" }, - "409": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Authentication is required." }, - "423": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Organization authority is required." }, - "503": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Organization not found." + }, + "400": { + "description": "Invalid organization data." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization state conflict." } } } }, - "/api/v1/admin/spaces/{id}/": { + "/api/v1/admin/organizations/{id}/invitations/": { "get": { - "operationId": "api_v1_admin_spaces_retrieve", - "summary": "Retrieve a bookable space", + "operationId": "api_v1_admin_organizations_invitations_retrieve", + "summary": "List organization invitations without bearer tokens", "parameters": [ { "in": "path", @@ -47416,7 +47825,7 @@ } ], "tags": [ - "Admin bookings" + "Admin organizations" ], "security": [ { @@ -47428,22 +47837,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookableSpaceAdmin" + "$ref": "#/components/schemas/OrganizationInvitationList" } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid request." - }, "401": { "content": { "application/json": { @@ -47452,7 +47851,7 @@ } } }, - "description": "Authentication required." + "description": "Authentication is required." }, "403": { "content": { @@ -47462,7 +47861,7 @@ } } }, - "description": "Permission denied." + "description": "Organization authority is required." }, "404": { "content": { @@ -47472,13 +47871,13 @@ } } }, - "description": "Not found." + "description": "Organization not found." } } }, - "patch": { - "operationId": "api_v1_admin_spaces_partial_update", - "summary": "Update a bookable space", + "post": { + "operationId": "api_v1_admin_organizations_invitations_create", + "summary": "Create a single-use organization invitation", "parameters": [ { "in": "path", @@ -47490,23 +47889,23 @@ } ], "tags": [ - "Admin bookings" + "Admin organizations" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedBookableSpaceWrite" + "$ref": "#/components/schemas/OrganizationInvitationCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedBookableSpaceWrite" + "$ref": "#/components/schemas/OrganizationInvitationCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedBookableSpaceWrite" + "$ref": "#/components/schemas/OrganizationInvitationCreate" } } } @@ -47517,17 +47916,17 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookableSpaceAdmin" + "$ref": "#/components/schemas/OrganizationInvitationCreated" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -47535,9 +47934,9 @@ } } }, - "description": "Invalid request." + "description": "Authentication is required." }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -47545,17 +47944,7 @@ } } }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." + "description": "Organization authority is required." }, "404": { "content": { @@ -47565,7 +47954,10 @@ } } }, - "description": "Not found." + "description": "Organization not found." + }, + "400": { + "description": "Invalid organization data." }, "409": { "content": { @@ -47575,15 +47967,14 @@ } } }, - "description": "Workflow conflict." + "description": "Organization state conflict." } } } }, - "/api/v1/admin/spaces/{id}/booking-rules/": { + "/api/v1/admin/organizations/{id}/makerspaces/": { "get": { - "operationId": "api_v1_admin_spaces_booking_rules_retrieve", - "summary": "Retrieve booking rules for a space", + "operationId": "api_v1_admin_organizations_makerspaces_retrieve", "parameters": [ { "in": "path", @@ -47595,7 +47986,7 @@ } ], "tags": [ - "Admin bookings" + "Admin organizations" ], "security": [ { @@ -47607,48 +47998,82 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookableSpaceBookingRules" + "$ref": "#/components/schemas/OrganizationLinkPage" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { "type": "object", - "additionalProperties": {} + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Invalid booking-rule values or bookings module disabled." + "description": "Not signed in." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Permission denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Not found." + "description": "Not found, not visible to this actor, or the surface is not enabled here." } } }, - "patch": { - "operationId": "api_v1_admin_spaces_booking_rules_partial_update", - "summary": "Update booking rules for a space", + "post": { + "operationId": "api_v1_admin_organizations_makerspaces_create", "parameters": [ { "in": "path", @@ -47660,26 +48085,27 @@ } ], "tags": [ - "Admin bookings" + "Admin organizations" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedBookableSpaceBookingRules" + "$ref": "#/components/schemas/OrganizationLinkWrite" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedBookableSpaceBookingRules" + "$ref": "#/components/schemas/OrganizationLinkWrite" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedBookableSpaceBookingRules" + "$ref": "#/components/schemas/OrganizationLinkWrite" } } - } + }, + "required": true }, "security": [ { @@ -47691,7 +48117,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookableSpaceBookingRules" + "$ref": "#/components/schemas/OrganizationLink" } } }, @@ -47702,182 +48128,161 @@ "application/json": { "schema": { "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Invalid booking-rule values or bookings module disabled." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Not found." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, - "409": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Workflow conflict." - } - } - } - }, - "/api/v1/admin/spaces/{id}/bookings/": { - "get": { - "operationId": "api_v1_admin_spaces_bookings_retrieve", - "summary": "List bookings for a space", - "parameters": [ - { - "in": "query", - "name": "booking_id", - "schema": { - "type": "integer", - "maximum": 9223372036854775807, - "minimum": 1, - "format": "int64" - } - }, - { - "in": "query", - "name": "ends_at", - "schema": { - "type": "string", - "format": "date-time" - } - }, - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "query", - "name": "ordering", - "schema": { - "type": "string" - }, - "description": "Allowlisted sort field; prefix with '-' for descending." - }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer" - }, - "description": "Page number, 24 rows per page." - }, - { - "in": "query", - "name": "starts_at", - "schema": { - "type": "string", - "format": "date-time" - } + "description": "Not signed in." }, - { - "in": "query", - "name": "status", - "schema": { - "enum": [ - "pending", - "confirmed", - "rejected", - "cancelled", - "completed", - "no_show" - ], - "type": "string", - "minLength": 1 - }, - "description": "* `pending` - Pending\n* `confirmed` - Confirmed\n* `rejected` - Rejected\n* `cancelled` - Cancelled\n* `completed` - Completed\n* `no_show` - No-show" - } - ], - "tags": [ - "Admin bookings" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookingListResponse" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "" + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "400": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Invalid request." + "description": "Not found, not visible to this actor, or the surface is not enabled here." }, - "401": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Authentication required." + "description": "Conflicts with the current state." }, - "403": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Permission denied." + "description": "Makerspace writes are frozen for tenant migration." }, - "404": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Not found." + "description": "A required dependency is unavailable." } } - }, - "post": { - "operationId": "api_v1_admin_spaces_bookings_create", - "summary": "Book a space on behalf of an active member", + } + }, + "/api/v1/admin/organizations/{id}/makerspaces/{makerspace_id}/": { + "delete": { + "operationId": "api_v1_admin_organizations_makerspaces_destroy", "parameters": [ { "in": "path", @@ -47886,125 +48291,189 @@ "type": "integer" }, "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true } ], "tags": [ - "Admin bookings" + "Admin organizations" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StaffBookingInput" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/StaffBookingInput" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/StaffBookingInput" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BookingAdmin" - } - } - }, - "description": "" + "204": { + "description": "No response body" }, "400": { "content": { "application/json": { "schema": { "type": "object", - "additionalProperties": {} + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Invalid input or bookings module disabled." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Authentication required." + "description": "Not signed in." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Permission denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Not found." + "description": "Not found, not visible to this actor, or the surface is not enabled here." }, "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Workflow conflict." + "description": "Conflicts with the current state." }, "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Tenant frozen for migration." + "description": "Makerspace writes are frozen for tenant migration." }, "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Tenant write gate unavailable." + "description": "A required dependency is unavailable." } } } }, - "/api/v1/admin/spaces/{id}/deactivate/": { - "post": { - "operationId": "api_v1_admin_spaces_deactivate_create", - "summary": "Deactivate a bookable space", + "/api/v1/admin/organizations/{id}/memberships/": { + "get": { + "operationId": "api_v1_admin_organizations_memberships_retrieve", + "summary": "List organization memberships", "parameters": [ { "in": "path", @@ -48016,7 +48485,7 @@ } ], "tags": [ - "Admin bookings" + "Admin organizations" ], "security": [ { @@ -48028,22 +48497,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookableSpaceAdmin" + "$ref": "#/components/schemas/OrganizationMembershipList" } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid request." - }, "401": { "content": { "application/json": { @@ -48052,7 +48511,7 @@ } } }, - "description": "Authentication required." + "description": "Authentication is required." }, "403": { "content": { @@ -48062,7 +48521,7 @@ } } }, - "description": "Permission denied." + "description": "Organization authority is required." }, "404": { "content": { @@ -48072,25 +48531,14 @@ } } }, - "description": "Not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Workflow conflict." + "description": "Organization not found." } } } }, - "/api/v1/admin/spaces/{id}/eligible-members/": { - "get": { - "operationId": "api_v1_admin_spaces_eligible_members_retrieve", - "summary": "List active members for staff booking", + "/api/v1/admin/organizations/{id}/memberships/{membership_id}/": { + "patch": { + "operationId": "api_v1_admin_organizations_memberships_partial_update", "parameters": [ { "in": "path", @@ -48101,16 +48549,36 @@ "required": true }, { - "in": "query", - "name": "page", + "in": "path", + "name": "membership_id", "schema": { "type": "integer" - } + }, + "required": true } ], "tags": [ - "Admin bookings" + "Admin organizations" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedOrganizationMembershipUpdate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedOrganizationMembershipUpdate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedOrganizationMembershipUpdate" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -48121,7 +48589,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookingEligibleMemberList" + "$ref": "#/components/schemas/OrganizationMembership" } } }, @@ -48132,61 +48600,121 @@ "application/json": { "schema": { "type": "object", - "additionalProperties": {} + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Invalid input or bookings module disabled." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Authentication required." + "description": "Not signed in." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Permission denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "Not found." + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "409": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } + } + } + }, + "description": "Conflicts with the current state." } } } }, - "/api/v1/admin/spaces/{id}/image/": { - "delete": { - "operationId": "api_v1_admin_spaces_image_destroy", - "summary": "Delete a space image", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "/api/v1/admin/organized-events/": { + "get": { + "operationId": "api_v1_admin_organized_events_retrieve", + "description": "List the events this actor's organizations organize, across venues.\n\nOrganizer authority is deliberately per-event and grants nothing over the venue, so an\norganizer at a venue their organization is not linked to cannot use the venue's event\nlist -- which left the per-event endpoints reachable only by someone who already knew a\ndatabase id. This is the discoverable surface for that authority: it lists exactly the\nevents the organizer predicate matches and confers no venue authority whatsoever.", + "summary": "List events organized by the actor's organizations", "tags": [ - "Admin bookings" + "Events" ], "security": [ { @@ -48194,18 +48722,15 @@ } ], "responses": { - "204": { - "description": "No response body" - }, - "400": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/EventListResponse" } } }, - "description": "Invalid request." + "description": "" }, "401": { "content": { @@ -48215,7 +48740,7 @@ } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { @@ -48225,7 +48750,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -48235,58 +48760,68 @@ } } }, - "description": "Not found." - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Service unavailable." + "description": "" } } } }, - "/api/v1/admin/spaces/{id}/image/finalize/": { - "post": { - "operationId": "api_v1_admin_spaces_image_finalize_create", - "summary": "Finalize and attach a space image", + "/api/v1/admin/platform/analytics/{report_key}": { + "get": { + "operationId": "api_v1_admin_platform_analytics_retrieve", + "description": "Platform-only counts with the definition's typed chart_spec. Hidden tenants appear solely as anonymous daily platform-service counts; own-provider and business usage exclude them. Connect includes attributed ledger facts, with no provider-attempt failure history.", + "summary": "Get superadmin platform analytics", "parameters": [ { - "in": "path", - "name": "id", + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month", + "week" + ] + } + }, + { + "in": "query", + "name": "limit", "schema": { "type": "integer" + } + }, + { + "in": "path", + "name": "report_key", + "schema": { + "type": "string", + "enum": [ + "platform-services", + "platform-usage" + ] }, "required": true + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } } ], "tags": [ - "Admin bookings" + "Analytics" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SpaceImageFinalizeRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/SpaceImageFinalizeRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/SpaceImageFinalizeRequest" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -48297,7 +48832,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookableSpaceAdmin" + "$ref": "#/components/schemas/PlatformAnalyticsReport" } } }, @@ -48307,17 +48842,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Invalid request." + "description": "Invalid report request." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, @@ -48327,7 +48862,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, @@ -48337,73 +48872,51 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Not found." - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Service unavailable." + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/spaces/{id}/image/presign/": { - "post": { - "operationId": "api_v1_admin_spaces_image_presign_create", - "summary": "Create a space image upload URL", + "/api/v1/admin/platform/analytics/makerspaces": { + "get": { + "operationId": "api_v1_admin_platform_analytics_makerspaces_retrieve", + "description": "Paginated picker. A hidden makerspace requires an active local membership granting the selected report's action. Platform analytics always use their stricter visibility policy.", + "summary": "List makerspaces available for tenant analytics", "parameters": [ { - "in": "path", - "name": "id", + "in": "query", + "name": "page", "schema": { "type": "integer" + } + }, + { + "in": "query", + "name": "report_key", + "schema": { + "type": "string" }, - "required": true + "description": "Tenant report key; defaults to summary." } ], "tags": [ - "Admin bookings" + "Analytics" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SpaceImagePresignRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/SpaceImagePresignRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/SpaceImagePresignRequest" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SpaceImagePresignResponse" + "$ref": "#/components/schemas/AnalyticsMakerspacePage" } } }, @@ -48413,17 +48926,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Invalid request." + "description": "Invalid report request." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, @@ -48433,7 +48946,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, @@ -48443,57 +48956,80 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Not found." - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Service unavailable." + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/spaces/{id}/recurring-bookings/": { - "post": { - "operationId": "api_v1_admin_spaces_recurring_bookings_create", - "description": "Create a finite weekly series in makerspace local time. Each occurrence has its own flat charge and cancellation. Conflicts are reported, never silently skipped.", + "/api/v1/admin/platform/backup-destinations": { + "get": { + "operationId": "api_v1_admin_platform_backup_destinations_retrieve", + "summary": "List offsite backup destinations", "parameters": [ { - "in": "path", - "name": "id", + "in": "query", + "name": "page", "schema": { "type": "integer" }, - "required": true + "description": "Page number (24 rows per page)." } ], "tags": [ - "Admin bookings" + "Backup destinations" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackupDestinationPage" + } + } + }, + "description": "" + }, + "404": { + "description": "The requested resource does not exist in the actor's scope." + }, + "401": { + "description": "Authentication is required." + }, + "403": { + "description": "The authenticated actor is not authorized." + } + } + }, + "post": { + "operationId": "api_v1_admin_platform_backup_destinations_create", + "summary": "Register an S3-compatible offsite backup destination", + "tags": [ + "Backup destinations" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecurringBookingInput" + "$ref": "#/components/schemas/BackupDestination" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/RecurringBookingInput" + "$ref": "#/components/schemas/BackupDestination" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/RecurringBookingInput" + "$ref": "#/components/schemas/BackupDestination" } } }, @@ -48505,48 +49041,36 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecurrenceResult" + "$ref": "#/components/schemas/BackupDestination" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "description": "The request is invalid for the current lifecycle state." }, "401": { - "description": "Not signed in." + "description": "Authentication is required." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "409": { - "description": "Conflicts with the current state." - }, - "423": { - "description": "Makerspace writes are frozen for tenant migration." - }, - "503": { - "description": "A required dependency is unavailable." + "description": "The authenticated actor is not authorized." } } } }, - "/api/v1/admin/stock-transfers/{id}": { + "/api/v1/admin/platform/backup-destinations/{destination_id}": { "get": { - "operationId": "api_v1_admin_stock_transfers_retrieve", - "summary": "Retrieve stock transfer", + "operationId": "api_v1_admin_platform_backup_destinations_retrieve_2", + "summary": "Get one offsite backup destination", "parameters": [ { "in": "path", - "name": "id", + "name": "destination_id", "schema": { "type": "integer" }, @@ -48554,7 +49078,7 @@ } ], "tags": [ - "Stock transfers" + "Backup destinations" ], "security": [ { @@ -48566,42 +49090,30 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StockTransfer" + "$ref": "#/components/schemas/BackupDestination" } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GenericObject" - } - } - }, - "description": "Invalid request." + "404": { + "description": "The requested resource does not exist in the actor's scope." }, "401": { - "description": "Authentication credentials were not provided." + "description": "Authentication is required." }, "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." + "description": "The authenticated actor is not authorized." } } - } - }, - "/api/v1/admin/stocktakes/{id}": { - "get": { - "operationId": "api_v1_admin_stocktakes_retrieve", - "summary": "Retrieve stocktake", + }, + "patch": { + "operationId": "api_v1_admin_platform_backup_destinations_partial_update", + "summary": "Update or rotate credentials of an offsite destination", "parameters": [ { "in": "path", - "name": "id", + "name": "destination_id", "schema": { "type": "integer" }, @@ -48609,8 +49121,27 @@ } ], "tags": [ - "Stocktake" + "Backup destinations" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedBackupDestination" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedBackupDestination" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedBackupDestination" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -48621,42 +49152,35 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Stocktake" + "$ref": "#/components/schemas/BackupDestination" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GenericObject" - } - } - }, - "description": "Invalid request or stocktake state." + "description": "The request is invalid for the current lifecycle state." + }, + "404": { + "description": "The requested resource does not exist in the actor's scope." }, "401": { - "description": "Authentication credentials were not provided." + "description": "Authentication is required." }, "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." + "description": "The authenticated actor is not authorized." } } } }, - "/api/v1/admin/stocktakes/{id}/apply-adjustments": { + "/api/v1/admin/platform/backup-destinations/{destination_id}/retire": { "post": { - "operationId": "api_v1_admin_stocktakes_apply_adjustments_create", - "summary": "Apply stocktake adjustments", + "operationId": "api_v1_admin_platform_backup_destinations_retire_create", + "summary": "Retire a destination: stop pushing and wipe its stored credentials", "parameters": [ { "in": "path", - "name": "id", + "name": "destination_id", "schema": { "type": "integer" }, @@ -48664,7 +49188,7 @@ } ], "tags": [ - "Stocktake" + "Backup destinations" ], "security": [ { @@ -48676,42 +49200,35 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Stocktake" + "$ref": "#/components/schemas/BackupDestination" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GenericObject" - } - } - }, - "description": "Invalid request or stocktake state." + "description": "The request is invalid for the current lifecycle state." + }, + "404": { + "description": "The requested resource does not exist in the actor's scope." }, "401": { - "description": "Authentication credentials were not provided." + "description": "Authentication is required." }, "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." + "description": "The authenticated actor is not authorized." } } } }, - "/api/v1/admin/stocktakes/{id}/approve": { + "/api/v1/admin/platform/backup-destinations/{destination_id}/test": { "post": { - "operationId": "api_v1_admin_stocktakes_approve_create", - "summary": "Approve stocktake", + "operationId": "api_v1_admin_platform_backup_destinations_test_create", + "summary": "Probe write, read-back and delete on a destination", "parameters": [ { "in": "path", - "name": "id", + "name": "destination_id", "schema": { "type": "integer" }, @@ -48719,7 +49236,7 @@ } ], "tags": [ - "Stocktake" + "Backup destinations" ], "security": [ { @@ -48731,50 +49248,48 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Stocktake" + "$ref": "#/components/schemas/DestinationProbe" } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GenericObject" - } - } - }, - "description": "Invalid request or stocktake state." + "404": { + "description": "The requested resource does not exist in the actor's scope." }, "401": { - "description": "Authentication credentials were not provided." + "description": "Authentication is required." }, "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." + "description": "The authenticated actor is not authorized." } } } }, - "/api/v1/admin/stocktakes/{id}/complete": { - "post": { - "operationId": "api_v1_admin_stocktakes_complete_create", - "summary": "Complete stocktake", + "/api/v1/admin/platform/backup-destinations/{destination_id}/uploads": { + "get": { + "operationId": "api_v1_admin_platform_backup_destinations_uploads_retrieve", + "summary": "List the upload journal of one destination", "parameters": [ { "in": "path", - "name": "id", + "name": "destination_id", "schema": { "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + }, + "description": "Page number (24 rows per page)." } ], "tags": [ - "Stocktake" + "Backup destinations" ], "security": [ { @@ -48786,145 +49301,114 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Stocktake" + "$ref": "#/components/schemas/BackupDestinationUploadPage" } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GenericObject" - } - } - }, - "description": "Invalid request or stocktake state." + "404": { + "description": "The requested resource does not exist in the actor's scope." }, "401": { - "description": "Authentication credentials were not provided." + "description": "Authentication is required." }, "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." + "description": "The authenticated actor is not authorized." } } } }, - "/api/v1/admin/stocktakes/{id}/count-lines": { + "/api/v1/admin/platform/backup-destinations/push": { "post": { - "operationId": "api_v1_admin_stocktakes_count_lines_create", - "summary": "Count stocktake line", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_admin_platform_backup_destinations_push_create", + "summary": "Start an offsite push now instead of waiting for the schedule", "tags": [ - "Stocktake" + "Backup destinations" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StocktakeLineInput" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/StocktakeLineInput" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/StocktakeLineInput" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StocktakeLine" + "$ref": "#/components/schemas/DestinationDispatch" } } }, "description": "" }, - "400": { + "503": { + "description": "The backup worker is unavailable." + }, + "401": { + "description": "Authentication is required." + }, + "403": { + "description": "The authenticated actor is not authorized." + } + } + } + }, + "/api/v1/admin/platform/backup-settings": { + "get": { + "operationId": "api_v1_admin_platform_backup_settings_retrieve", + "summary": "Get deployment backup settings", + "tags": [ + "Backup" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GenericObject" + "$ref": "#/components/schemas/PlatformBackupSettings" } } }, - "description": "Invalid request or stocktake state." + "description": "" }, "401": { - "description": "Authentication credentials were not provided." + "description": "Authentication is required." }, "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." + "description": "The authenticated actor is not authorized." } } - } - }, - "/api/v1/admin/stocktakes/{id}/resolve-scan": { - "post": { - "operationId": "api_v1_admin_stocktakes_resolve_scan_create", - "summary": "Resolve a scanned QR to a stocktake count target", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + }, + "patch": { + "operationId": "api_v1_admin_platform_backup_settings_partial_update", + "summary": "Update deployment backup settings", "tags": [ - "Stocktake" + "Backup" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StocktakeScanInput" + "$ref": "#/components/schemas/PatchedPlatformBackupSettings" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/StocktakeScanInput" + "$ref": "#/components/schemas/PatchedPlatformBackupSettings" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/StocktakeScanInput" + "$ref": "#/components/schemas/PatchedPlatformBackupSettings" } } - }, - "required": true + } }, "security": [ { @@ -48936,70 +49420,31 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StocktakeScanResult" + "$ref": "#/components/schemas/PlatformBackupSettings" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GenericObject" - } - } - }, - "description": "Invalid request or stocktake state." + "description": "The request is invalid for the current lifecycle state." }, "401": { - "description": "Authentication credentials were not provided." + "description": "Authentication is required." }, "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." + "description": "The authenticated actor is not authorized." } } } }, - "/api/v1/admin/users/{id}/reset-password": { - "post": { - "operationId": "api_v1_admin_users_reset_password_create", - "summary": "Reset a staff user's password (temp + force change)", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "/api/v1/admin/platform/backups": { + "get": { + "operationId": "api_v1_admin_platform_backups_list", + "summary": "List full-deployment backup archives", "tags": [ - "Admin users" + "Backup" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ResetPasswordRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ResetPasswordRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ResetPasswordRequest" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -49010,31 +49455,28 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResetPasswordResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/BackupArchive" + } } } }, "description": "" + }, + "401": { + "description": "Authentication is required." + }, + "403": { + "description": "The authenticated actor is not authorized." } } - } - }, - "/api/v1/admin/users/{id}/restore-access": { + }, "post": { - "operationId": "api_v1_admin_users_restore_access_create", - "summary": "Restore user access", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_admin_platform_backups_create", + "summary": "Request an age-encrypted full-deployment backup", "tags": [ - "Admin users" + "Backup" ], "security": [ { @@ -49042,60 +49484,61 @@ } ], "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/User" + "$ref": "#/components/schemas/BackupArchive" } } }, "description": "" + }, + "503": { + "description": "The backup worker is unavailable." + }, + "401": { + "description": "Authentication is required." + }, + "403": { + "description": "The authenticated actor is not authorized." } } } }, - "/api/v1/admin/users/{id}/restrict": { + "/api/v1/admin/platform/backups/{archive_id}/rehydrate": { "post": { - "operationId": "api_v1_admin_users_restrict_create", - "summary": "Restrict or suspend a user", + "operationId": "api_v1_admin_platform_backups_rehydrate_create", + "summary": "Restore an archive's primary bytes from a verified offsite copy", "parameters": [ { "in": "path", - "name": "id", + "name": "archive_id", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Admin users" + "Backup destinations" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RestrictUser" - }, - "examples": { - "RestrictARequester": { - "value": { - "status": "restricted", - "reason": "Unreturned loan under review" - }, - "summary": "Restrict a requester" - } + "$ref": "#/components/schemas/Rehydrate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/RestrictUser" + "$ref": "#/components/schemas/Rehydrate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/RestrictUser" + "$ref": "#/components/schemas/Rehydrate" } } }, @@ -49107,36 +49550,75 @@ } ], "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/User" + "$ref": "#/components/schemas/DestinationDispatch" } } }, "description": "" + }, + "400": { + "description": "The request is invalid for the current lifecycle state." + }, + "404": { + "description": "The requested resource does not exist in the actor's scope." + }, + "503": { + "description": "The backup worker is unavailable." + }, + "401": { + "description": "Authentication is required." + }, + "403": { + "description": "The authenticated actor is not authorized." } } } }, - "/api/v1/admin/users/inventory-managers": { + "/api/v1/admin/platform/dashboard": { "get": { - "operationId": "api_v1_admin_users_inventory_managers_list", - "summary": "List or create staff memberships", - "parameters": [ + "operationId": "api_v1_admin_platform_dashboard_retrieve", + "summary": "Read platform operations dashboard", + "tags": [ + "Platform" + ], + "security": [ { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } + "jwtAuth": [] } ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformDashboard" + } + } + }, + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + } + } + } + }, + "/api/v1/admin/platform/email-settings": { + "get": { + "operationId": "api_v1_admin_platform_email_settings_retrieve", + "summary": "Retrieve or update platform email settings", "tags": [ - "Admin users" + "Platform" ], "security": [ { @@ -49148,39 +49630,44 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedStaffMembershipList" + "$ref": "#/components/schemas/PlatformEmailSettings" } } }, "description": "" + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." } } }, - "post": { - "operationId": "api_v1_admin_users_inventory_managers_create", - "summary": "List or create staff memberships", + "patch": { + "operationId": "api_v1_admin_platform_email_settings_partial_update", + "summary": "Retrieve or update platform email settings", "tags": [ - "Admin users" + "Platform" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/PatchedPlatformEmailSettings" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/PatchedPlatformEmailSettings" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/PatchedPlatformEmailSettings" } } - }, - "required": true + } }, "security": [ { @@ -49188,36 +49675,67 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/PlatformEmailSettings" } } }, "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." } } } }, - "/api/v1/admin/users/machine-managers": { + "/api/v1/admin/platform/mobile-readiness": { "get": { - "operationId": "api_v1_admin_users_machine_managers_list", - "summary": "List or create staff memberships", - "parameters": [ + "operationId": "api_v1_admin_platform_mobile_readiness_retrieve", + "description": "Read-only configuration checks, grouped by registration/environment and platform. No provider probes or secret values.", + "summary": "Native mobile integration readiness", + "tags": [ + "Platform" + ], + "security": [ { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } + "jwtAuth": [] } ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MobileReadiness" + } + } + }, + "description": "" + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + } + } + } + }, + "/api/v1/admin/platform/payment-settings": { + "get": { + "operationId": "api_v1_admin_platform_payment_settings_retrieve", + "summary": "Retrieve or update platform Stripe Connect settings", "tags": [ - "Admin users" + "Platform" ], "security": [ { @@ -49229,39 +49747,47 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedStaffMembershipList" + "$ref": "#/components/schemas/PlatformStripeConnectSettings" } } }, "description": "" + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Self-hosted deployment: Stripe Connect is a managed-only surface." } } }, - "post": { - "operationId": "api_v1_admin_users_machine_managers_create", - "summary": "List or create staff memberships", + "patch": { + "operationId": "api_v1_admin_platform_payment_settings_partial_update", + "summary": "Retrieve or update platform Stripe Connect settings", "tags": [ - "Admin users" + "Platform" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/PatchedPlatformStripeConnectSettings" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/PatchedPlatformStripeConnectSettings" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/PatchedPlatformStripeConnectSettings" } } - }, - "required": true + } }, "security": [ { @@ -49269,36 +49795,72 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/PlatformStripeConnectSettings" } } }, "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Self-hosted deployment: Stripe Connect is a managed-only surface." } } } }, - "/api/v1/admin/users/space-managers": { + "/api/v1/admin/platform/problems": { "get": { - "operationId": "api_v1_admin_users_space_managers_list", - "summary": "List or create staff memberships", - "parameters": [ - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } + "operationId": "api_v1_admin_platform_problems_retrieve", + "summary": "Run read-only platform problem checks on demand", + "tags": [ + "Platform" + ], + "security": [ + { + "jwtAuth": [] } ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformProblems" + } + } + }, + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + } + } + } + }, + "/api/v1/admin/platform/restores": { + "get": { + "operationId": "api_v1_admin_platform_restores_list", + "summary": "List deployment restore operations", "tags": [ - "Admin users" + "Backup" ], "security": [ { @@ -49310,35 +49872,44 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedStaffMembershipList" + "type": "array", + "items": { + "$ref": "#/components/schemas/RestoreOperation" + } } } }, "description": "" + }, + "401": { + "description": "Authentication is required." + }, + "403": { + "description": "The authenticated actor is not authorized." } } }, "post": { - "operationId": "api_v1_admin_users_space_managers_create", - "summary": "List or create staff memberships", + "operationId": "api_v1_admin_platform_restores_create", + "summary": "Record restore intent for the privileged host supervisor", "tags": [ - "Admin users" + "Backup" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/RestoreCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/RestoreCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/RestoreCreate" } } }, @@ -49350,35 +49921,45 @@ } ], "responses": { - "201": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/RestoreOperation" } } }, "description": "" + }, + "400": { + "description": "The request is invalid for the current lifecycle state." + }, + "401": { + "description": "Authentication is required." + }, + "403": { + "description": "The authenticated actor is not authorized." } } } }, - "/api/v1/admin/warranty/{id}/documents": { + "/api/v1/admin/platform/restores/{restore_id}": { "get": { - "operationId": "api_v1_admin_warranty_documents_list", - "summary": "List documents attached to a warranty", + "operationId": "api_v1_admin_platform_restores_retrieve", + "summary": "Get restore stage, diff, and decision deadline", "parameters": [ { "in": "path", - "name": "id", + "name": "restore_id", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Admin warranty" + "Backup" ], "security": [ { @@ -49390,78 +49971,57 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WarrantyDocument" - } + "$ref": "#/components/schemas/RestoreOperation" } } }, "description": "" }, + "404": { + "description": "The requested resource does not exist in the actor's scope." + }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Authentication is required." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "The authenticated actor is not authorized." } } - }, + } + }, + "/api/v1/admin/platform/restores/{restore_id}/decision": { "post": { - "operationId": "api_v1_admin_warranty_documents_create", - "summary": "Finalize an uploaded warranty document", + "operationId": "api_v1_admin_platform_restores_decision_create", + "summary": "Decide a quiesced in-place restore", "parameters": [ { "in": "path", - "name": "id", + "name": "restore_id", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Admin warranty" + "Backup" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WarrantyDocumentFinalize" + "$ref": "#/components/schemas/RestoreDecision" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/WarrantyDocumentFinalize" + "$ref": "#/components/schemas/RestoreDecision" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/WarrantyDocumentFinalize" + "$ref": "#/components/schemas/RestoreDecision" } } }, @@ -49473,91 +50033,86 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WarrantyDocument" + "$ref": "#/components/schemas/RestoreOperation" } } }, "description": "" }, "400": { - "description": "Invalid or duplicate warranty document." + "description": "The request is invalid for the current lifecycle state." + }, + "404": { + "description": "The requested resource does not exist in the actor's scope." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Authentication is required." }, "403": { + "description": "The authenticated actor is not authorized." + } + } + } + }, + "/api/v1/admin/platform/social-auth-settings": { + "get": { + "operationId": "api_v1_admin_platform_social_auth_settings_retrieve", + "summary": "Retrieve or update platform social auth settings", + "tags": [ + "Platform" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PlatformSocialAuthSettings" } } }, "description": "" }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "401": { + "description": "Not signed in." }, - "503": { - "description": "Warranty document storage is unavailable." + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." } } - } - }, - "/api/v1/admin/warranty/{id}/documents/presign": { - "post": { - "operationId": "api_v1_admin_warranty_documents_presign_create", - "summary": "Create a warranty document upload URL", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + }, + "patch": { + "operationId": "api_v1_admin_platform_social_auth_settings_partial_update", + "summary": "Retrieve or update platform social auth settings", "tags": [ - "Admin warranty" + "Platform" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WarrantyDocumentPresign" + "$ref": "#/components/schemas/PatchedPlatformSocialAuthSettings" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/WarrantyDocumentPresign" + "$ref": "#/components/schemas/PatchedPlatformSocialAuthSettings" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/WarrantyDocumentPresign" + "$ref": "#/components/schemas/PatchedPlatformSocialAuthSettings" } } - }, - "required": true + } }, "security": [ { @@ -49565,130 +50120,110 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WarrantyDocumentUploadResponse" + "$ref": "#/components/schemas/PlatformSocialAuthSettings" } } }, "description": "" }, "400": { - "description": "Invalid document upload request." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + } + } + } + }, + "/api/v1/admin/platform/tenant-migrations/deployment-identity": { + "get": { + "operationId": "api_v1_admin_platform_tenant_migrations_deployment_identity_retrieve", + "summary": "Read this deployment's signing identity and target age recipient", + "tags": [ + "Tenant migration" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeploymentIdentity" } } }, "description": "" }, - "403": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "State conflict." }, - "404": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" - }, - "503": { - "description": "Warranty document storage is unavailable." - } - } - } - }, - "/api/v1/admin/warranty/documents/{id}": { - "delete": { - "operationId": "api_v1_admin_warranty_documents_destroy", - "summary": "Delete a warranty document", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin warranty" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "204": { - "description": "No response body" + "description": "Authentication required." }, - "401": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "403": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "404": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } } }, - "/api/v1/admin/warranty/documents/{id}/url": { + "/api/v1/admin/platform/tenant-migrations/imports": { "get": { - "operationId": "api_v1_admin_warranty_documents_url_retrieve", - "summary": "Create a signed warranty document view URL", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_admin_platform_tenant_migrations_imports_list", + "summary": "List tenant import jobs", "tags": [ - "Admin warranty" + "Tenant migration" ], "security": [ { @@ -49700,7 +50235,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WarrantyDocumentUrl" + "type": "array", + "items": { + "$ref": "#/components/schemas/ImportJob" + } } } }, @@ -49710,60 +50248,65 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "404": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "503": { - "description": "Warranty document storage is unavailable." + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Unexpected migration failure." } } - } - }, - "/api/v1/auth/change-password": { + }, "post": { - "operationId": "api_v1_auth_change_password_create", - "summary": "Change current user's password", + "operationId": "api_v1_admin_platform_tenant_migrations_imports_create", + "summary": "Create an import job from an age-encrypted archive upload", "tags": [ - "Auth" + "Tenant migration" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChangePassword" + "$ref": "#/components/schemas/ImportCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ChangePassword" + "$ref": "#/components/schemas/ImportCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ChangePassword" + "$ref": "#/components/schemas/ImportCreate" } } }, @@ -49775,197 +50318,174 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChangePasswordResponse" + "$ref": "#/components/schemas/ImportJob" } } }, "description": "" }, "400": { - "description": "Password validation failed." - }, - "401": { - "description": "Authentication credentials were not provided." - } - } - } - }, - "/api/v1/auth/claim/redeem": { - "post": { - "operationId": "api_v1_auth_claim_redeem_create", - "summary": "Redeem a staff-issued member claim code", - "tags": [ - "Auth" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ClaimRedemption" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ClaimRedemption" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ClaimRedemption" - } - } - }, - "required": true - }, - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ClaimRedemptionResponse" + "$ref": "#/components/schemas/FieldValidationError" } } }, - "description": "" + "description": "Field-keyed validation errors." }, - "400": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "Invalid or expired claim code." - }, - "404": { - "description": "Makerspace not found." - }, - "429": { - "description": "Redemption rate limit exceeded." - } - } - } - }, - "/api/v1/auth/device/attestation-challenge": { - "post": { - "operationId": "api_v1_auth_device_attestation_challenge_create", - "tags": [ - "Device auth" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceIdentity" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/DeviceIdentity" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/DeviceIdentity" - } - } + "description": "State conflict." }, - "required": true - }, - "responses": { - "200": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceChallengeResponse" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, - "400": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "503": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } } }, - "/api/v1/auth/device/grants": { + "/api/v1/admin/platform/tenant-migrations/imports/{job_id}": { "get": { - "operationId": "api_v1_auth_device_grants_list", - "tags": [ - "Device auth" - ], - "security": [ + "operationId": "api_v1_admin_platform_tenant_migrations_imports_retrieve", + "summary": "Read a tenant import job", + "parameters": [ { - "jwtAuth": [] - } - ], + "in": "path", + "name": "job_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Tenant migration" + ], + "security": [ + { + "jwtAuth": [] + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DeviceGrant" - } + "$ref": "#/components/schemas/ImportJob" } } }, "description": "" }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Not found." + }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Superadmin access required." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Throttle limit exceeded." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Unexpected migration failure." } } } }, - "/api/v1/auth/device/grants/{grant_id}": { - "delete": { - "operationId": "api_v1_auth_device_grants_destroy", + "/api/v1/admin/platform/tenant-migrations/imports/{job_id}/identity-decisions": { + "get": { + "operationId": "api_v1_admin_platform_tenant_migrations_imports_identity_decisions_list", + "summary": "Read the exact archived identity decision list", "parameters": [ { "in": "path", - "name": "grant_id", + "name": "job_id", "schema": { "type": "string", "format": "uuid" @@ -49974,7 +50494,7 @@ } ], "tags": [ - "Device auth" + "Tenant migration" ], "security": [ { @@ -49982,14 +50502,14 @@ } ], "responses": { - "204": { - "description": "No response body" - }, - "401": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "array", + "items": { + "$ref": "#/components/schemas/ClosureIdentity" + } } } }, @@ -49999,90 +50519,206 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Not found." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Superadmin access required." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Throttle limit exceeded." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Unexpected migration failure." } } - } - }, - "/api/v1/auth/device/login": { + }, "post": { - "operationId": "api_v1_auth_device_login_create", + "operationId": "api_v1_admin_platform_tenant_migrations_imports_identity_decisions_create", + "summary": "Submit all per-person import identity decisions", + "parameters": [ + { + "in": "path", + "name": "job_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], "tags": [ - "Device auth" + "Tenant migration" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceLogin" + "$ref": "#/components/schemas/ImportDecisionList" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/DeviceLogin" + "$ref": "#/components/schemas/ImportDecisionList" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/DeviceLogin" + "$ref": "#/components/schemas/ImportDecisionList" } } }, "required": true }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceLoginResult" + "$ref": "#/components/schemas/ImportJob" } } }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FieldValidationError" + } + } + }, + "description": "Field-keyed validation errors." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "State conflict." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Not found." + }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "503": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Unexpected migration failure." } } } }, - "/api/v1/auth/device/logout": { + "/api/v1/admin/platform/tenant-migrations/imports/{job_id}/pairings/{pairing_id}/abort": { "post": { - "operationId": "api_v1_auth_device_logout_create", + "operationId": "api_v1_admin_platform_tenant_migrations_imports_pairings_abort_create", + "summary": "Abort an importing target and issue its signed proof", + "parameters": [ + { + "in": "path", + "name": "job_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + }, + { + "in": "path", + "name": "pairing_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], "tags": [ - "Device auth" + "Tenant migration" ], "security": [ { @@ -50094,126 +50730,117 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceLogoutResponse" + "$ref": "#/components/schemas/CutoverOutcome" } } }, "description": "" }, - "401": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Not found." }, - "403": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" - } - } - } - }, - "/api/v1/auth/device/refresh": { - "post": { - "operationId": "api_v1_auth_device_refresh_create", - "tags": [ - "Device auth" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceRefresh" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/DeviceRefresh" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/DeviceRefresh" - } - } + "description": "State conflict." }, - "required": true - }, - "responses": { - "200": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceRefreshResponse" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, - "400": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "401": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "429": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } } }, - "/api/v1/auth/email-verification/confirm": { + "/api/v1/admin/platform/tenant-migrations/imports/{job_id}/pairings/{pairing_id}/activate": { "post": { - "operationId": "api_v1_auth_email_verification_confirm_create", + "operationId": "api_v1_admin_platform_tenant_migrations_imports_pairings_activate_create", + "summary": "Activate an imported target with the source receipt", + "parameters": [ + { + "in": "path", + "name": "job_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + }, + { + "in": "path", + "name": "pairing_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], "tags": [ - "Auth" + "Tenant migration" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailVerificationConfirm" + "$ref": "#/components/schemas/CutoverReceiptRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/EmailVerificationConfirm" + "$ref": "#/components/schemas/CutoverReceiptRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/EmailVerificationConfirm" + "$ref": "#/components/schemas/CutoverReceiptRequest" } } }, @@ -50229,221 +50856,231 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberVerificationAck" + "$ref": "#/components/schemas/CutoverOutcome" } } }, "description": "" }, "400": { - "description": "Invalid or expired verification code." - }, - "401": { - "description": "Authentication credentials were not provided." - }, - "403": { - "description": "Permission denied." - }, - "429": { - "description": "Request throttled." - } - } - } - }, - "/api/v1/auth/email-verification/resend": { - "post": { - "operationId": "api_v1_auth_email_verification_resend_create", - "tags": [ - "Auth" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberVerificationAck" + "$ref": "#/components/schemas/FieldValidationError" } } }, - "description": "" - }, - "400": { - "description": "Invalid request." - }, - "401": { - "description": "Authentication credentials were not provided." - }, - "403": { - "description": "Permission denied." + "description": "Field-keyed validation errors." }, - "429": { - "description": "Request throttled." - } - } - } - }, - "/api/v1/auth/forgot-password": { - "post": { - "operationId": "api_v1_auth_forgot_password_create", - "summary": "Request an emailed password reset code", - "tags": [ - "Auth" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ForgotPasswordRequest" + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ForgotPasswordRequest" + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ForgotPasswordRequest" - } - } + "description": "State conflict." }, - "required": true - }, - "responses": { - "200": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PasswordResetAcknowledgement" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, - "400": { - "description": "Invalid request." + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Superadmin access required." }, "429": { - "description": "Request throttled." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Throttle limit exceeded." }, - "503": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecoveryUnavailable" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } } }, - "/api/v1/auth/login": { + "/api/v1/admin/platform/tenant-migrations/imports/{job_id}/run": { "post": { - "operationId": "api_v1_auth_login_create", - "description": "Takes a set of user credentials and returns an access and refresh JSON web\ntoken pair to prove the authentication of those credentials.", - "summary": "Log in with a password", + "operationId": "api_v1_admin_platform_tenant_migrations_imports_run_create", + "summary": "Run an identity-decided tenant import", + "parameters": [ + { + "in": "path", + "name": "job_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], "tags": [ - "Auth" + "Tenant migration" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LoginRequest" - }, - "examples": { - "StaffLogin": { - "value": { - "username": "admin", - "password": "secret-password", - "surface": "staff" - }, - "summary": "Staff login" - } + "$ref": "#/components/schemas/ImportRun" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/LoginRequest" + "$ref": "#/components/schemas/ImportRun" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/LoginRequest" + "$ref": "#/components/schemas/ImportRun" } } - }, - "required": true + } }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PasswordLoginResult" + "$ref": "#/components/schemas/ImportJob" } } }, "description": "" }, "400": { - "description": "Invalid request." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FieldValidationError" + } + } + }, + "description": "Field-keyed validation errors." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "State conflict." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Not found." + }, + "503": { + "description": "Import worker unavailable." }, "401": { - "description": "Invalid credentials or inactive account." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "Account access is restricted." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Superadmin access required." }, "429": { - "description": "Request throttled." - }, - "503": { - "description": "Authentication write boundary unavailable." - } - } - } - }, - "/api/v1/auth/logout": { - "post": { - "operationId": "api_v1_auth_logout_create", - "summary": "Log out and clear refresh cookie", - "tags": [ - "Auth" - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LogoutResponse" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" - }, - "401": { - "description": "Refresh token could not be blacklisted." + "description": "Throttle limit exceeded." }, - "403": { - "description": "CSRF check failed." + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Unexpected migration failure." } } } }, - "/api/v1/auth/me": { + "/api/v1/admin/platform/tenant-migrations/imports/{job_id}/verification": { "get": { - "operationId": "api_v1_auth_me_retrieve", - "summary": "Get current staff profile", + "operationId": "api_v1_admin_platform_tenant_migrations_imports_verification_retrieve", + "summary": "Read the import verification report", + "parameters": [ + { + "in": "path", + "name": "job_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], "tags": [ - "Auth" + "Tenant migration" ], "security": [ { @@ -50455,84 +51092,81 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AuthUserPayload" + "$ref": "#/components/schemas/VerificationReport" } } }, "description": "" }, - "400": { - "description": "Invalid request." - }, - "401": { - "description": "Authentication credentials were not provided." - }, - "403": { - "description": "Permission denied." - }, - "429": { - "description": "Request throttled." - } - } - } - }, - "/api/v1/auth/member-sign-up": { - "post": { - "operationId": "api_v1_auth_member_sign_up_create", - "tags": [ - "Auth" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MemberSignUp" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/MemberSignUp" + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/MemberSignUp" - } - } + "description": "Not found." }, - "required": true - }, - "responses": { - "200": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberVerificationAck" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" - }, - "400": { - "description": "Invalid details." + "description": "State conflict." }, "401": { - "description": "Authentication failed." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "Permission denied." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Superadmin access required." }, "429": { - "description": "Request throttled." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Throttle limit exceeded." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Unexpected migration failure." } } } }, - "/api/v1/auth/mfa": { + "/api/v1/admin/platform/tenant-migrations/pairings": { "get": { - "operationId": "api_v1_auth_mfa_retrieve", + "operationId": "api_v1_admin_platform_tenant_migrations_pairings_list", + "summary": "List pinned migration pairings", "tags": [ - "MFA" + "Tenant migration" ], "security": [ { @@ -50544,43 +51178,78 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MFAStatus" + "type": "array", + "items": { + "$ref": "#/components/schemas/Pairing" + } } } }, "description": "" }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Superadmin access required." }, "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Throttle limit exceeded." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Unexpected migration failure." } } }, "post": { - "operationId": "api_v1_auth_mfa_create", + "operationId": "api_v1_admin_platform_tenant_migrations_pairings_create", + "summary": "Approve and pin source/target deployment identities", "tags": [ - "MFA" + "Tenant migration" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MFAPassword" + "$ref": "#/components/schemas/PairingCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MFAPassword" + "$ref": "#/components/schemas/PairingCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MFAPassword" + "$ref": "#/components/schemas/PairingCreate" } } }, @@ -50592,115 +51261,96 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MFAChallengeResponse" + "$ref": "#/components/schemas/Pairing" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." - }, - "503": { - "description": "A required dependency is unavailable." - } - } - } - }, - "/api/v1/auth/mfa/confirm": { - "post": { - "operationId": "api_v1_auth_mfa_confirm_create", - "tags": [ - "MFA" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MFAVerify" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/MFAVerify" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FieldValidationError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/MFAVerify" - } - } + "description": "Field-keyed validation errors." }, - "required": true - }, - "responses": { - "200": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MFACompleted" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" - }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "description": "State conflict." }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Superadmin access required." }, "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Throttle limit exceeded." }, - "503": { - "description": "A required dependency is unavailable." + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Unexpected migration failure." } } } }, - "/api/v1/auth/mfa/disable": { - "post": { - "operationId": "api_v1_auth_mfa_disable_create", + "/api/v1/admin/printer-jobs/{id}": { + "get": { + "operationId": "api_v1_admin_printer_jobs_retrieve", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], "tags": [ - "MFA" + "Devices" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MFAPassword" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/MFAPassword" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/MFAPassword" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -50711,222 +51361,223 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MFAStatus" + "$ref": "#/components/schemas/Job" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Invalid payload or adapter module disabled." }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Authentication/signature/replay refused." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Authority or device revoked." }, - "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Target outside scope or absent." }, - "503": { - "description": "A required dependency is unavailable." + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Conflicting command/report." + }, + "410": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Delivery lease expired." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Tenant frozen for migration." } } } }, - "/api/v1/auth/mfa/enroll": { + "/api/v1/admin/printer-jobs/{id}/apply": { "post": { - "operationId": "api_v1_auth_mfa_enroll_create", + "operationId": "api_v1_admin_printer_jobs_apply_create", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], "tags": [ - "MFA" + "Devices" + ], + "security": [ + { + "jwtAuth": [] + } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MFAChallenge" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/MFAChallenge" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/MFAChallenge" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MFAEnrollment" + "$ref": "#/components/schemas/Job" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." - }, - "503": { - "description": "A required dependency is unavailable." - } - } - } - }, - "/api/v1/auth/mfa/recovery": { - "post": { - "operationId": "api_v1_auth_mfa_recovery_create", - "tags": [ - "MFA" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MFAPassword" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/MFAPassword" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/MFAPassword" - } - } + "description": "Invalid payload or adapter module disabled." }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MFARecovery" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" - }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." + "description": "Authentication/signature/replay refused." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." - }, - "503": { - "description": "A required dependency is unavailable." - } - } - } - }, - "/api/v1/auth/mfa/verify": { - "post": { - "operationId": "api_v1_auth_mfa_verify_create", - "tags": [ - "MFA" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MFAVerify" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/MFAVerify" + "description": "Authority or device revoked." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/MFAVerify" - } - } + "description": "Target outside scope or absent." }, - "required": true - }, - "responses": { - "200": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MFACompleted" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" - }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "description": "Conflicting command/report." }, - "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." + "410": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Delivery lease expired." }, - "503": { - "description": "A required dependency is unavailable." + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Tenant frozen for migration." } } } }, - "/api/v1/auth/organization-invitations/redeem/": { + "/api/v1/admin/products/{id}/assets/generate": { "post": { - "operationId": "api_v1_auth_organization_invitations_redeem_create", - "summary": "Redeem a single-use organization invitation", + "operationId": "api_v1_admin_products_assets_generate_create", + "summary": "Generate asset units", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Auth" + "Asset units" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrganizationInvitationRedeem" + "$ref": "#/components/schemas/AssetGenerate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/OrganizationInvitationRedeem" + "$ref": "#/components/schemas/AssetGenerate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/OrganizationInvitationRedeem" + "$ref": "#/components/schemas/AssetGenerate" } } }, @@ -50938,62 +51589,140 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrganizationInvitationRedeemed" + "$ref": "#/components/schemas/AssetGenerateResult" } } }, "description": "" }, "400": { - "description": "Malformed token." + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + }, + "description": "Invalid input, disabled module, or non-draft print batch." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] } } }, - "description": "" + "description": "QR management permission required." }, "404": { - "description": "Invitation not found." + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + }, + "description": "Batch or inventory target not in scope." }, - "409": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] } } }, - "description": "" + "description": "Tenant frozen for migration." } } } }, - "/api/v1/auth/phone": { - "delete": { - "operationId": "api_v1_auth_phone_destroy", - "description": "Detach the number. Always safe: every account keeps an email credential.", - "summary": "Unlink the phone number", + "/api/v1/admin/qr-print-batches/{id}": { + "get": { + "operationId": "api_v1_admin_qr_print_batches_retrieve", + "summary": "Retrieve QR print batch", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Auth" + "QR print batches" ], "security": [ { @@ -51005,49 +51734,32 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PhoneStatus" + "$ref": "#/components/schemas/QrPrintBatchDetail" } } }, "description": "" - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." } } } }, - "/api/v1/auth/phone/link/confirm": { - "post": { - "operationId": "api_v1_auth_phone_link_confirm_create", - "description": "Attach the verified number to the caller's account.", - "summary": "Confirm and link a phone number", + "/api/v1/admin/qr-print-batches/{id}/download": { + "get": { + "operationId": "api_v1_admin_qr_print_batches_download_retrieve", + "summary": "Download QR print batch as a ZIP of captioned SVGs", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Auth" + "QR print batches" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PhoneConfirm" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PhoneConfirm" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PhoneConfirm" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -51056,46 +51768,50 @@ "responses": { "200": { "content": { - "application/json": { + "application/zip": { "schema": { - "$ref": "#/components/schemas/PhoneStatus" + "type": "string", + "format": "binary" } } }, "description": "" - }, - "400": { - "description": "Invalid or expired code." - }, - "429": { - "description": "Request throttled." } } } }, - "/api/v1/auth/phone/link/start": { + "/api/v1/admin/qr-print-batches/{id}/items": { "post": { - "operationId": "api_v1_auth_phone_link_start_create", - "description": "Send a code to a number the caller wants to attach to their own account.", - "summary": "Request a code to link a phone number", + "operationId": "api_v1_admin_qr_print_batches_items_create", + "summary": "Add QR code to draft print batch", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Auth" + "QR print batches" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PhoneStart" + "$ref": "#/components/schemas/QrPrintBatchItemCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PhoneStart" + "$ref": "#/components/schemas/QrPrintBatchItemCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PhoneStart" + "$ref": "#/components/schemas/QrPrintBatchItemCreate" } } }, @@ -51107,719 +51823,869 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PhoneStartResponse" + "$ref": "#/components/schemas/QrPrintBatchItemResult" } } }, "description": "" }, "400": { - "description": "Invalid or already-linked number." - }, - "404": { - "description": "Phone sign-in is not configured." - }, - "429": { - "description": "Request throttled." - } - } - } - }, - "/api/v1/auth/phone/login/confirm": { - "post": { - "operationId": "api_v1_auth_phone_login_confirm_create", - "description": "Exchange a valid code for a MEMBER session. Never mints a staff session.", - "summary": "Sign in with a phone code", - "tags": [ - "Auth" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PhoneConfirm" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PhoneConfirm" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PhoneConfirm" - } - } - }, - "required": true - }, - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PhoneLoginResult" + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] } } }, - "description": "" + "description": "Invalid input, disabled module, or non-draft print batch." }, "401": { - "description": "The first-factor account is no longer available." - }, - "400": { - "description": "Invalid or expired code." - }, - "404": { - "description": "Phone sign-in is not configured." - }, - "429": { - "description": "Request throttled." - }, - "503": { - "description": "Authentication write boundary unavailable." - } - } - } - }, - "/api/v1/auth/phone/login/start": { - "post": { - "operationId": "api_v1_auth_phone_login_start_create", - "description": "Request a login code for an already-verified number.", - "summary": "Request a phone sign-in code", - "tags": [ - "Auth" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PhoneStart" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PhoneStart" + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PhoneStart" - } - } + "description": "Authentication required." }, - "required": true - }, - "responses": { - "200": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PhoneStartResponse" + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] } } }, - "description": "" - }, - "400": { - "description": "Invalid request." + "description": "QR management permission required." }, "404": { - "description": "Phone sign-in is not configured." - }, - "429": { - "description": "Request throttled." - } - } - } - }, - "/api/v1/auth/refresh": { - "post": { - "operationId": "api_v1_auth_refresh_create", - "description": "Takes a refresh type JSON web token and returns an access type JSON web\ntoken if the refresh token is valid.", - "summary": "Refresh access token", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + }, + "description": "Batch or inventory target not in scope." + }, + "423": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + }, + "description": "Tenant frozen for migration." + } + } + } + }, + "/api/v1/admin/qr-print-batches/{id}/label-commands": { + "get": { + "operationId": "api_v1_admin_qr_print_batches_label_commands_list", + "summary": "List label commands for a QR batch", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + } + ], "tags": [ - "Auth" + "Devices" + ], + "security": [ + { + "jwtAuth": [] + } ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RefreshResponse" + "$ref": "#/components/schemas/PaginatedLabelCommandList" } } }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + }, + "description": "DRF field/module validation errors or non-field validation messages." + }, "401": { - "description": "Missing, invalid, or replayed refresh token." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Authentication required or invalid signature." }, "403": { - "description": "CSRF check failed or account restricted." - } - } - } - }, - "/api/v1/auth/reset-password": { - "post": { - "operationId": "api_v1_auth_reset_password_create", - "summary": "Confirm an OTP or coexisting legacy password reset link", - "tags": [ - "Auth" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ResetPasswordConfirmRequest" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ResetPasswordConfirmRequest" + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ResetPasswordConfirmRequest" - } - } - } - }, - "responses": { - "200": { + "description": "Command or batch not in scope." + }, + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PasswordUpdated" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Idempotency or delivery conflict." }, - "400": { + "410": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResetPasswordConfirmError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Batch no longer available." }, - "429": { - "description": "Request throttled." + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Tenant frozen for migration." } } - } - }, - "/api/v1/auth/social/apple": { + }, "post": { - "operationId": "api_v1_auth_social_apple_create", + "operationId": "api_v1_admin_qr_print_batches_label_commands_create", + "summary": "Send or reprint a QR batch", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Social auth" + "Devices" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SocialLogin" + "$ref": "#/components/schemas/LabelCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/SocialLogin" + "$ref": "#/components/schemas/LabelCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/SocialLogin" + "$ref": "#/components/schemas/LabelCreate" } } }, "required": true }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SocialLoginResponse" + "$ref": "#/components/schemas/LabelCommand" + } + } + }, + "description": "" + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabelCommand" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + }, + "description": "DRF field/module validation errors or non-field validation messages." }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Authentication required or invalid signature." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Command or batch not in scope." }, "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Includes `social_device_restart_required` when burned pre-grant inputs must be replaced." + "description": "Idempotency or delivery conflict." }, - "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." + "410": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Batch no longer available." }, - "503": { - "description": "A required dependency is unavailable." + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Tenant frozen for migration." } } } }, - "/api/v1/auth/social/github/authorize": { + "/api/v1/admin/qr/{id}/print": { + "get": { + "operationId": "api_v1_admin_qr_print_retrieve", + "summary": "Render QR label SVG", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "QR assets" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "description": "SVG QR label." + } + } + } + }, + "/api/v1/admin/qr/{id}/rebind-target": { "post": { - "operationId": "api_v1_auth_social_github_authorize_create", + "operationId": "api_v1_admin_qr_rebind_target_create", + "summary": "Rebind a saved QR to another product/asset (cross-makerspace = superadmin) and optionally rename", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Social auth" + "QR assets" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GithubBrowserStart" + "$ref": "#/components/schemas/QrRebindTarget" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/GithubBrowserStart" + "$ref": "#/components/schemas/QrRebindTarget" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/GithubBrowserStart" + "$ref": "#/components/schemas/QrRebindTarget" } } }, "required": true }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GithubBrowserStartResponse" + "$ref": "#/components/schemas/QrRebindResult" } } }, "description": "" }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." - }, - "503": { - "description": "A required dependency is unavailable." + "409": { + "description": "Outstanding loan or target QR conflict." } } } }, - "/api/v1/auth/social/github/callback": { + "/api/v1/admin/qr/{id}/revoke": { "post": { - "operationId": "api_v1_auth_social_github_callback_create", + "operationId": "api_v1_admin_qr_revoke_create", + "summary": "Revoke active QR code", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Social auth" + "QR assets" + ], + "security": [ + { + "jwtAuth": [] + } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OidcBrowserCallback" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/OidcBrowserCallback" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/OidcBrowserCallback" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OidcBrowserLoginResponse" + "$ref": "#/components/schemas/QrCode" } } }, "description": "" - }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "409": { - "description": "Conflicts with the current state." - }, - "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." - }, - "503": { - "description": "A required dependency is unavailable." } } } }, - "/api/v1/auth/social/google": { + "/api/v1/admin/qr/boxes": { "post": { - "operationId": "api_v1_auth_social_google_create", + "operationId": "api_v1_admin_qr_boxes_create", + "summary": "Create a QR-coded box", "tags": [ - "Social auth" + "QR assets" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SocialLogin" + "$ref": "#/components/schemas/CreateBoxQr" + }, + "examples": { + "CreateAQR-codedBox": { + "value": { + "makerspace_id": 1, + "label": "Electronics Box A", + "location": "Bench Storage", + "description": "Issued hardware kit box" + }, + "summary": "Create a QR-coded box" + } } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/SocialLogin" + "$ref": "#/components/schemas/CreateBoxQr" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/SocialLogin" + "$ref": "#/components/schemas/CreateBoxQr" } } }, "required": true }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SocialLoginResponse" + "$ref": "#/components/schemas/Box" } } }, "description": "" - }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Includes `social_device_restart_required` when burned pre-grant inputs must be replaced." - }, - "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." - }, - "503": { - "description": "A required dependency is unavailable." } } } }, - "/api/v1/auth/social/nonce": { + "/api/v1/admin/qr/containers": { "post": { - "operationId": "api_v1_auth_social_nonce_create", + "operationId": "api_v1_admin_qr_containers_create", + "summary": "Create a QR-coded box", "tags": [ - "Social auth" + "QR assets" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SocialNonce" + "$ref": "#/components/schemas/CreateBoxQr" + }, + "examples": { + "CreateAQR-codedBox": { + "value": { + "makerspace_id": 1, + "label": "Electronics Box A", + "location": "Bench Storage", + "description": "Issued hardware kit box" + }, + "summary": "Create a QR-coded box" + } } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/SocialNonce" + "$ref": "#/components/schemas/CreateBoxQr" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/SocialNonce" + "$ref": "#/components/schemas/CreateBoxQr" } } }, "required": true }, - "responses": { - "200": { + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SocialNonceResponse" + "$ref": "#/components/schemas/Box" } } }, "description": "" - }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." } } } }, - "/api/v1/auth/social/oidc/{slug}": { + "/api/v1/admin/qr/resolve": { "post": { - "operationId": "api_v1_auth_social_oidc_create", - "description": "Login through a deployment-configured OIDC provider named in the URL.\n\nThe slug is resolved to a configured row on every request rather than captured at\nimport time, so disabling a provider takes effect immediately instead of at the next\nrestart -- the same reason `provider_for_slug` refuses disabled and half-filled rows.", - "parameters": [ - { - "in": "path", - "name": "slug", - "schema": { - "type": "string" - }, - "required": true - } - ], + "operationId": "api_v1_admin_qr_resolve_create", + "description": "Resolve the opaque payload encoded in a physical QR label to its target (box, product, or asset) and the scanner actions the caller may take. `payload` is the raw 32-char lowercase hex token printed on the label (Python `uuid4().hex`, e.g. `3f9a1c2b4d5e6f7081920a1b2c3d4e5f`) - it is the value stored as `QrCode.payload` (and `Box.code` for boxes), not a URL or JSON. Resolving a QR also records an immutable scanner-lookup scan event.", + "summary": "Resolve QR target and scanner allowed actions", "tags": [ - "Social auth" + "QR assets" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SocialLogin" + "$ref": "#/components/schemas/QrResolve" + }, + "examples": { + "ResolveAScannedQRPayload": { + "value": { + "payload": "3f9a1c2b4d5e6f7081920a1b2c3d4e5f" + }, + "summary": "Resolve a scanned QR payload" + } } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/SocialLogin" + "$ref": "#/components/schemas/QrResolve" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/SocialLogin" + "$ref": "#/components/schemas/QrResolve" } } }, "required": true }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SocialLoginResponse" + "$ref": "#/components/schemas/QrResolveResult" + }, + "examples": { + "ResolvedBoxQR": { + "value": { + "qr": { + "id": 12, + "makerspace": 1, + "payload": "3f9a1c2b4d5e6f7081920a1b2c3d4e5f", + "target_type": "box", + "target_id": 5, + "status": "active", + "created_at": "2026-06-27T09:30:00Z", + "updated_at": "2026-06-27T09:30:00Z", + "revoked_at": null + }, + "target": { + "type": "box", + "id": 5, + "label": "Electronics Box A", + "code": "3f9a1c2b4d5e6f7081920a1b2c3d4e5f" + }, + "allowed_actions": [ + "contents", + "move_container", + "record_scan", + "revoke", + "view" + ] + }, + "summary": "Resolved box QR" + } } } }, "description": "" - }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Includes `social_device_restart_required` when burned pre-grant inputs must be replaced." - }, - "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." - }, - "503": { - "description": "A required dependency is unavailable." } } } }, - "/api/v1/auth/social/oidc/{slug}/authorize": { + "/api/v1/admin/qr/scan": { "post": { - "operationId": "api_v1_auth_social_oidc_authorize_create", - "parameters": [ - { - "in": "path", - "name": "slug", - "schema": { - "type": "string" - }, - "required": true - } - ], + "operationId": "api_v1_admin_qr_scan_create", + "description": "Context is limited to issue or return and scan events are immutable.", + "summary": "Record a QR scan", "tags": [ - "Social auth" + "QR assets" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OidcBrowserStart" + "$ref": "#/components/schemas/QrScan" + }, + "examples": { + "ScanQRDuringIssue": { + "value": { + "payload": "3f9a1c2b4d5e6f7081920a1b2c3d4e5f", + "context": "issue", + "request_id": 99 + }, + "summary": "Scan QR during issue" + } } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/OidcBrowserStart" + "$ref": "#/components/schemas/QrScan" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/OidcBrowserStart" + "$ref": "#/components/schemas/QrScan" } } }, "required": true }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OidcBrowserStartResponse" + "$ref": "#/components/schemas/QrScanResult" } } }, "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + } + } + } + }, + "/api/v1/admin/qr/tools": { + "post": { + "operationId": "api_v1_admin_qr_tools_create", + "summary": "Create or reuse a QR code for a product or asset", + "tags": [ + "QR assets" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateToolQr" } }, - "description": "" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/CreateToolQr" } }, - "description": "" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/CreateToolQr" } - }, - "description": "" + } }, - "503": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/QrCode" } } }, "description": "" - }, - "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." } } } }, - "/api/v1/auth/social/oidc/callback": { - "post": { - "operationId": "api_v1_auth_social_oidc_callback_create", + "/api/v1/admin/report-schedules/{id}": { + "patch": { + "operationId": "api_v1_admin_report_schedules_partial_update", + "summary": "Update report schedule", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Social auth" + "Reports" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OidcBrowserCallback" + "$ref": "#/components/schemas/PatchedReportSchedule" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/OidcBrowserCallback" + "$ref": "#/components/schemas/PatchedReportSchedule" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/OidcBrowserCallback" + "$ref": "#/components/schemas/PatchedReportSchedule" } } - }, - "required": true + } }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OidcBrowserLoginResponse" + "$ref": "#/components/schemas/ReportSchedule" } } }, @@ -51829,63 +52695,79 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Invalid schedule or module disabled." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Permission denied." }, - "409": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Makerspace or schedule not found." }, - "503": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Tenant writes are frozen." }, - "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Tenant write gate unavailable." } } - } - }, - "/api/v1/auth/social/providers": { - "get": { - "operationId": "api_v1_auth_social_providers_list", + }, + "delete": { + "operationId": "api_v1_admin_report_schedules_destroy", + "summary": "Delete report schedule", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Social auth" + "Reports" ], "security": [ { @@ -51893,104 +52775,88 @@ } ], "responses": { - "200": { + "204": { + "description": "No response body" + }, + "400": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SocialIdentity" - } + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Invalid schedule or module disabled." }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - } - } - }, - "post": { - "operationId": "api_v1_auth_social_providers_create", - "tags": [ - "Social auth" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SocialLink" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/SocialLink" + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/SocialLink" - } - } + "description": "Makerspace or schedule not found." }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SocialIdentity" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" - }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "409": { - "description": "Conflicts with the current state." + "description": "Tenant writes are frozen." }, "503": { - "description": "A required dependency is unavailable." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Tenant write gate unavailable." } } } }, - "/api/v1/auth/social/providers/{provider}": { - "delete": { - "operationId": "api_v1_auth_social_providers_destroy", + "/api/v1/admin/report-schedules/{id}/run-now": { + "post": { + "operationId": "api_v1_admin_report_schedules_run_now_create", + "summary": "Run a report schedule now", "parameters": [ { "in": "path", - "name": "provider", + "name": "id", "schema": { - "type": "string" + "type": "integer" }, "required": true } ], "tags": [ - "Social auth" + "Reports" ], "security": [ { @@ -51998,287 +52864,435 @@ } ], "responses": { - "204": { - "description": "No response body" - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "409": { - "description": "Conflicts with the current state." - } - } - } - }, - "/api/v1/auth/staff-invitations/accept": { - "post": { - "operationId": "api_v1_auth_staff_invitations_accept_create", - "tags": [ - "Staff invitations" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StaffInvitationAccept" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/StaffInvitationAccept" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportDelivery" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/StaffInvitationAccept" - } - } + "description": "" }, - "required": true - }, - "responses": { - "200": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StaffInvitationAccepted" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Schedule inactive or skipped." }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Invalid schedule or module disabled." }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." }, - "423": { - "description": "Makerspace writes are frozen for tenant migration." + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or schedule not found." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Tenant writes are frozen." }, "503": { - "description": "A required dependency is unavailable." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Tenant write gate unavailable." } } } }, - "/api/v1/backups/download/{archive_id}/{token}": { + "/api/v1/admin/reports/{report_key}/export": { "get": { - "operationId": "api_v1_backups_download_retrieve", - "summary": "Consume a one-use backup download", + "operationId": "api_v1_admin_reports_export_retrieve", + "summary": "Export aggregate report", "parameters": [ { - "in": "path", - "name": "archive_id", + "in": "query", + "name": "end", "schema": { "type": "string", - "format": "uuid" + "format": "date" + } + }, + { + "in": "query", + "name": "format", + "schema": { + "type": "string", + "enum": [ + "csv", + "xlsx" + ] + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month", + "week" + ] + } + }, + { + "in": "query", + "name": "months", + "schema": { + "type": "integer" }, - "required": true + "description": "Retention anniversary in months (1-120); default 3, 6 and 12." }, { "in": "path", - "name": "token", + "name": "report_key", "schema": { - "type": "string" + "type": "string", + "enum": [ + "asset-usage", + "audit-activity", + "booking-lead-time", + "booking-occupancy", + "booking-utilization", + "cash-book", + "certification-coverage", + "communications-health", + "community-engagement", + "consumable-pools", + "consumable-usage", + "damaged-lost", + "damaged-missing", + "device-health", + "event-attendance", + "event-checkin-times", + "event-feedback-scores", + "event-funnel", + "event-revenue", + "event-signups", + "evidence-compliance", + "fablab-health", + "import-quality", + "inventory-control", + "inventory-utilisation", + "inventory-watch-demand", + "loan-throughput", + "machine-downtime", + "machine-errors", + "machine-service", + "machine-usage", + "machine-usage-heatmap", + "maintenance-activity", + "member-activity", + "member-retention", + "membership-growth", + "membership-revenue", + "mobile-auth", + "mobile-devices", + "module-operational-health", + "most-lent", + "organization-membership", + "payment-cash-book", + "payment-reconciliation", + "payments-ageing", + "platform-services", + "platform-usage", + "presence-activity", + "printer-jobs", + "printer-service", + "problem-reports", + "procurement-performance", + "qr-scans", + "recently-added", + "request-funnel", + "revenue-over-time", + "service-queue-times", + "stock-health", + "stock-transfers", + "stocktake-variance", + "summary", + "taken-items", + "warranty-expiry" + ] }, "required": true - } - ], - "tags": [ - "Backup" - ], - "responses": { - "200": { - "description": "Age-encrypted archive stream." }, - "404": { - "description": "Invalid or expired download." - } - } - } - }, - "/api/v1/bootstrap": { - "get": { - "operationId": "api_v1_bootstrap_retrieve", - "summary": "Resolve tenant and frontend-safe configuration", - "parameters": [ { "in": "query", - "name": "slug", + "name": "start", "schema": { - "type": "string" + "type": "string", + "format": "date" } }, { "in": "query", - "name": "tenant", + "name": "status", "schema": { - "type": "string" + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "loan_deposit", + "loan_late_fee", + "machine_service_request", + "makerspace_membership", + "membership_request", + "membership_term" + ] } } ], "tags": [ - "Tenant bootstrap" + "Reports" ], "security": [ { "jwtAuth": [] - }, - {} + } ], "responses": { + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Render rate limit exceeded." + }, "200": { + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "text/csv": { + "schema": { + "type": "string" + } + }, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "" + }, + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TenantBootstrap" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Frontend-safe tenant bootstrap payload." + "description": "Invalid report request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "No active tenant frontend matched." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/config": { + "/api/v1/admin/reports/catalog": { "get": { - "operationId": "api_v1_config_retrieve", - "summary": "Return frontend-safe platform configuration", + "operationId": "api_v1_admin_reports_catalog_retrieve", + "summary": "List deployment report catalog", "tags": [ - "Platform" + "Reports" ], "security": [ - {} + { + "jwtAuth": [] + } ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicConfig" + "$ref": "#/components/schemas/ReportCatalog" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "503": { - "description": "A required dependency is unavailable." - } - } - } - }, - "/api/v1/data-exports/download/{job_id}/{token}": { - "get": { - "operationId": "api_v1_data_exports_download_retrieve", - "summary": "Consume a one-use export download URL", - "parameters": [ - { - "in": "path", - "name": "job_id", - "schema": { - "type": "string", - "format": "uuid" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } }, - "required": true + "description": "Invalid report request." }, - { - "in": "path", - "name": "token", - "schema": { - "type": "string" - }, - "required": true - } - ], - "tags": [ - "Data exports" - ], - "responses": { - "200": { + "401": { "content": { - "application/zip": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/ReportError" } - }, - "application/octet-stream": { + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Permission denied." }, "404": { - "description": "Download link is invalid, expired, or used." - }, - "503": { - "description": "Archive storage is unavailable." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/device/commands/{id}/ack": { + "/api/v1/admin/requests/{id}/accept": { "post": { - "operationId": "api_v1_device_commands_ack_create", + "operationId": "api_v1_admin_requests_accept_create", + "summary": "Accept borrow request", "parameters": [ { "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Device protocol" + "Admin requests" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Ack" + "$ref": "#/components/schemas/AcceptRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/Ack" + "$ref": "#/components/schemas/AcceptRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/Ack" + "$ref": "#/components/schemas/AcceptRequest" } } - }, - "required": true + } }, "security": [ { - "DeviceSignature": [] + "jwtAuth": [] } ], "responses": { @@ -52286,7 +53300,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CommandAck" + "$ref": "#/components/schemas/AdminRequest" } } }, @@ -52296,84 +53310,95 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid payload or adapter module disabled." + "description": "Invalid request." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication/signature/replay refused." + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authority or device revoked." + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Target outside scope or absent." + "description": "Not found." }, "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Conflicting command/report." - }, - "410": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Delivery lease expired." - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant frozen for migration." + "description": "Workflow conflict." } } } }, - "/api/v1/device/commands/lease": { + "/api/v1/admin/requests/{id}/assign-box": { "post": { - "operationId": "api_v1_device_commands_lease_create", + "operationId": "api_v1_admin_requests_assign_box_create", + "summary": "Assign box to accepted request", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Device protocol" + "Admin requests" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignBox" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/AssignBox" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/AssignBox" + } + } + }, + "required": true + }, "security": [ { - "DeviceSignature": [] + "jwtAuth": [] } ], "responses": { @@ -52381,7 +53406,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LeaseResponse" + "$ref": "#/components/schemas/AdminRequest" } } }, @@ -52391,119 +53416,103 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid payload or adapter module disabled." + "description": "Invalid request." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication/signature/replay refused." + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authority or device revoked." + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Target outside scope or absent." + "description": "Not found." }, "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Conflicting command/report." - }, - "410": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Delivery lease expired." - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant frozen for migration." + "description": "Workflow conflict." } } } }, - "/api/v1/device/enrollments/{id}/challenge": { + "/api/v1/admin/requests/{id}/issue": { "post": { - "operationId": "api_v1_device_enrollments_challenge_create", - "summary": "Request a proof-of-possession challenge for a candidate key", + "operationId": "api_v1_admin_requests_issue_create", + "summary": "Issue accepted request", "parameters": [ { "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Device protocol" + "Admin requests" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChallengeRequest" + "$ref": "#/components/schemas/IssueRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ChallengeRequest" + "$ref": "#/components/schemas/IssueRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ChallengeRequest" + "$ref": "#/components/schemas/IssueRequest" } } }, "required": true }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChallengeResponse" + "$ref": "#/components/schemas/AdminRequest" } } }, @@ -52513,37 +53522,37 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Malformed key, challenge or signature, or module disabled." + "description": "Invalid request." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Not authenticated." + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Enrollment credentials or proof of possession rejected." + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -52553,79 +53562,73 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The key is already bound to another device, or rotation reused the key." + "description": "Workflow conflict." }, - "410": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The enrollment was used, revoked or has expired." - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Tenant frozen for migration." + "description": "Service unavailable." } } } }, - "/api/v1/device/enrollments/{id}/complete": { + "/api/v1/admin/requests/{id}/reject": { "post": { - "operationId": "api_v1_device_enrollments_complete_create", - "summary": "Complete enrollment by signing the challenge with the candidate key", + "operationId": "api_v1_admin_requests_reject_create", + "summary": "Reject borrow request", "parameters": [ { "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Device protocol" + "Admin requests" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CompleteRequest" + "$ref": "#/components/schemas/RejectRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/CompleteRequest" + "$ref": "#/components/schemas/RejectRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/CompleteRequest" + "$ref": "#/components/schemas/RejectRequest" } } }, "required": true }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ConnectedDeviceIdentity" + "$ref": "#/components/schemas/AdminRequest" } } }, @@ -52635,37 +53638,37 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Malformed key, challenge or signature, or module disabled." + "description": "Invalid request." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Not authenticated." + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Enrollment credentials or proof of possession rejected." + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -52675,131 +53678,63 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "The key is already bound to another device, or rotation reused the key." - }, - "410": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "The enrollment was used, revoked or has expired." - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant frozen for migration." + "description": "Workflow conflict." } } } }, - "/api/v1/device/heartbeat": { + "/api/v1/admin/requests/{id}/return": { "post": { - "operationId": "api_v1_device_heartbeat_create", - "summary": "Report device activity and recover an offline device (signed request)", - "tags": [ - "Device protocol" - ], - "security": [ - { - "DeviceSignature": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConnectedDeviceIdentity" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Module disabled." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Missing, malformed, stale, replayed or invalid signature." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Device not allowed on this route, revoked or tenant unavailable." - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Tenant frozen for migration." - } - } - } - }, - "/api/v1/device/label-commands/{id}/content": { - "get": { - "operationId": "api_v1_device_label_commands_content_retrieve", - "summary": "Download acknowledged label ZIP", + "operationId": "api_v1_admin_requests_return_create", + "summary": "Return issued request items", "parameters": [ { "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Device protocol" + "Admin requests" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReturnRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ReturnRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ReturnRequest" + } + } + }, + "required": true + }, "security": [ { - "DeviceSignature": [] + "jwtAuth": [] } ], "responses": { "200": { "content": { - "application/zip": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/AdminRequest" } } }, @@ -52809,38 +53744,27 @@ "content": { "application/json": { "schema": { - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "DRF field/module validation errors or non-field validation messages." + "description": "Invalid request." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required or invalid signature." + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -52850,78 +53774,67 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Command or batch not in scope." + "description": "Not found." }, "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Idempotency or delivery conflict." - }, - "410": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Batch no longer available." + "description": "Workflow conflict." }, - "423": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant frozen for migration." + "description": "Service unavailable." } } } }, - "/api/v1/device/label-commands/{id}/result": { + "/api/v1/admin/requests/{id}/return-due": { "post": { - "operationId": "api_v1_device_label_commands_result_create", - "summary": "Report an idempotent label result", + "operationId": "api_v1_admin_requests_return_due_create", + "summary": "Set request return due time", "parameters": [ { "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Device protocol" + "Admin requests" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LabelResult" + "$ref": "#/components/schemas/ReturnDue" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/LabelResult" + "$ref": "#/components/schemas/ReturnDue" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/LabelResult" + "$ref": "#/components/schemas/ReturnDue" } } }, @@ -52929,7 +53842,7 @@ }, "security": [ { - "DeviceSignature": [] + "jwtAuth": [] } ], "responses": { @@ -52937,7 +53850,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LabelReceipt" + "$ref": "#/components/schemas/AdminRequest" } } }, @@ -52947,38 +53860,27 @@ "content": { "application/json": { "schema": { - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "DRF field/module validation errors or non-field validation messages." + "description": "Invalid request." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required or invalid signature." + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -52988,85 +53890,54 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Command or batch not in scope." + "description": "Not found." }, "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Idempotency or delivery conflict." - }, - "410": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Batch no longer available." - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant frozen for migration." + "description": "Workflow conflict." } } } }, - "/api/v1/device/printer-jobs/{id}/result": { - "post": { - "operationId": "api_v1_device_printer_jobs_result_create", + "/api/v1/admin/requests/{id}/timeline": { + "get": { + "operationId": "api_v1_admin_requests_timeline_retrieve", + "description": "Scans, evidence, asset outcomes, accountability, problem reports and related audit rows (`kind=audit`; metadata is the presentation-catalogue projection), ordered by (at, kind, source id). Events with an `evidence_id` carry `evidence_retention` (`null` = no retention-state row, not proof the bytes exist). No object key or signed URL is ever returned; photo bytes stay behind the UPLOAD_EVIDENCE-gated evidence endpoint.", + "summary": "Read-only immutable timeline for one hardware request", "parameters": [ { "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + }, + "description": "Maximum history events to return. Defaults to 200; capped at 500." } ], "tags": [ - "Device protocol" + "Admin requests" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Result" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/Result" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Result" - } - } - }, - "required": true - }, "security": [ { - "DeviceSignature": [] + "jwtAuth": [] } ], "responses": { @@ -53074,7 +53945,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Job" + "$ref": "#/components/schemas/RequestTimelineResponse" } } }, @@ -53084,115 +53955,99 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid payload or adapter module disabled." + "description": "Invalid request." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication/signature/replay refused." + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authority or device revoked." + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Target outside scope or absent." + "description": "Not found." }, "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Conflicting command/report." - }, - "410": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Delivery lease expired." - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant frozen for migration." + "description": "Workflow conflict." } } } }, - "/api/v1/device/printer-jobs/{id}/telemetry": { - "post": { - "operationId": "api_v1_device_printer_jobs_telemetry_create", + "/api/v1/admin/search": { + "get": { + "operationId": "api_v1_admin_search_retrieve", + "description": "At most limit hits per entity (default 5, maximum 10). Members match username; bookings match space name or booking ID. Disabled or unauthorized entity lists contribute no hits.", + "summary": "Search authorized staff lists", "parameters": [ { - "in": "path", - "name": "id", + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "maximum": 10, + "minimum": 1, + "default": 5 + } + }, + { + "in": "query", + "name": "makerspace_id", + "schema": { + "type": "integer", + "maximum": 9223372036854775807, + "minimum": 1, + "format": "int64" + }, + "required": true + }, + { + "in": "query", + "name": "q", "schema": { "type": "string", - "format": "uuid" + "maxLength": 100, + "minLength": 2 }, "required": true } ], "tags": [ - "Device protocol" + "Staff lists" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Telemetry" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/Telemetry" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Telemetry" - } - } - }, - "required": true - }, "security": [ { - "DeviceSignature": [] + "jwtAuth": [] } ], "responses": { @@ -53200,7 +54055,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Job" + "$ref": "#/components/schemas/SearchOutput" } } }, @@ -53210,252 +54065,326 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid payload or adapter module disabled." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication/signature/replay refused." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authority or device revoked." + "description": "" }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Target outside scope or absent." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" - } - } - }, - "description": "Conflicting command/report." - }, - "410": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Delivery lease expired." + "description": "" }, - "423": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Tenant frozen for migration." + "description": "" } } } }, - "/api/v1/event-checkin-stations/{public_token}/roster/": { + "/api/v1/admin/space-pack-profiles": { "get": { - "operationId": "api_v1_event_checkin_stations_roster_retrieve", - "summary": "Download the station's minimal expiring attendee roster", + "operationId": "api_v1_admin_space_pack_profiles_list", "parameters": [ { - "in": "path", - "name": "public_token", + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", "schema": { - "type": "string", - "format": "uuid" - }, - "required": true + "type": "integer" + } } ], "tags": [ - "Event check-in stations" + "Platform" ], "security": [ { - "EventStationCookie": [] - }, - {} + "jwtAuth": [] + } ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OfflineRosterResponse" + "$ref": "#/components/schemas/PaginatedPackProfileList" } } }, "description": "" }, - "403": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PackError" } } }, - "description": "Invalid station credential, session, origin, feature, or time window." + "description": "" }, - "409": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PackError" } } }, - "description": "Invalid station credential, session, origin, feature, or time window." + "description": "" }, - "413": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PackError" } } }, - "description": "Invalid station credential, session, origin, feature, or time window." + "description": "" }, - "429": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PackError" } } }, - "description": "Invalid station credential, session, origin, feature, or time window." + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackError" + } + } + }, + "description": "" } } - } - }, - "/api/v1/event-checkin-stations/{public_token}/session/": { + }, "post": { - "operationId": "api_v1_event_checkin_stations_session_create", - "summary": "Exchange an event-scoped PIN for a station session", - "parameters": [ - { - "in": "path", - "name": "public_token", - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true - } - ], + "operationId": "api_v1_admin_space_pack_profiles_create", "tags": [ - "Event check-in stations" + "Platform" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StationPin" + "$ref": "#/components/schemas/PackProfile" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/StationPin" + "$ref": "#/components/schemas/PackProfile" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/StationPin" + "$ref": "#/components/schemas/PackProfile" } } }, "required": true }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { - "204": { - "description": "No response body" + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackProfile" + } + } + }, + "description": "" }, "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PackError" } } }, - "description": "Invalid station credential, session, origin, feature, or time window." + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackError" + } + } + }, + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PackError" } } }, - "description": "Invalid station credential, session, origin, feature, or time window." + "description": "" }, - "429": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PackError" } } }, - "description": "Invalid station credential, session, origin, feature, or time window." + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackError" + } + } + }, + "description": "" } } - }, - "delete": { - "operationId": "api_v1_event_checkin_stations_session_destroy", - "summary": "Clear the local station session cookie", + } + }, + "/api/v1/admin/spaces/{id}/": { + "get": { + "operationId": "api_v1_admin_spaces_retrieve", + "summary": "Retrieve a bookable space", "parameters": [ { "in": "path", - "name": "public_token", + "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Event check-in stations" + "Admin bookings" ], "security": [ { - "EventStationCookie": [] + "jwtAuth": [] } ], "responses": { - "204": { - "description": "No response body" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookableSpaceAdmin" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." }, "403": { "content": { @@ -53465,9 +54394,9 @@ } } }, - "description": "Invalid station credential, session, origin, feature, or time window." + "description": "Permission denied." }, - "429": { + "404": { "content": { "application/json": { "schema": { @@ -53475,61 +54404,56 @@ } } }, - "description": "Invalid station credential, session, origin, feature, or time window." + "description": "Not found." } } - } - }, - "/api/v1/event-checkin-stations/{public_token}/sync/": { - "post": { - "operationId": "api_v1_event_checkin_stations_sync_create", - "summary": "Synchronize queued PIN-station check-ins", + }, + "patch": { + "operationId": "api_v1_admin_spaces_partial_update", + "summary": "Update a bookable space", "parameters": [ { "in": "path", - "name": "public_token", + "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Event check-in stations" + "Admin bookings" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OfflineCheckInSyncRequest" + "$ref": "#/components/schemas/PatchedBookableSpaceWrite" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/OfflineCheckInSyncRequest" + "$ref": "#/components/schemas/PatchedBookableSpaceWrite" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/OfflineCheckInSyncRequest" + "$ref": "#/components/schemas/PatchedBookableSpaceWrite" } } - }, - "required": true + } }, "security": [ { - "EventStationCookie": [] - }, - {} + "jwtAuth": [] + } ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OfflineCheckInSyncResponse" + "$ref": "#/components/schemas/BookableSpaceAdmin" } } }, @@ -53543,7 +54467,17 @@ } } }, - "description": "Invalid station credential, session, origin, feature, or time window." + "description": "Invalid request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." }, "403": { "content": { @@ -53553,9 +54487,9 @@ } } }, - "description": "Invalid station credential, session, origin, feature, or time window." + "description": "Permission denied." }, - "410": { + "404": { "content": { "application/json": { "schema": { @@ -53563,9 +54497,9 @@ } } }, - "description": "Invalid station credential, session, origin, feature, or time window." + "description": "Not found." }, - "429": { + "409": { "content": { "application/json": { "schema": { @@ -53573,65 +54507,35 @@ } } }, - "description": "Invalid station credential, session, origin, feature, or time window." + "description": "Workflow conflict." } } } }, - "/api/v1/guest-admin/makerspace/{makerspace_id}/active-loans": { - "get": { - "operationId": "api_v1_guest_admin_makerspace_active_loans_list", - "summary": "List active loans awaiting return", + "/api/v1/admin/spaces/{id}/arrival/{booking_id}/pass/": { + "post": { + "operationId": "api_v1_admin_spaces_arrival_pass_create", + "description": "Requires resource check_in, bookings.passes/checkin and current active arrival. Answer-free projection, no override or cached PDF.", "parameters": [ - { - "name": "id", - "in": "query", - "required": false, - "description": "Select one result within the authorized list.", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 9223372036854775807 - } - }, { "in": "path", - "name": "makerspace_id", + "name": "booking_id", "schema": { "type": "integer" }, "required": true }, { - "name": "ordering", - "required": false, - "in": "query", - "description": "Which field to use when ordering the results.", - "schema": { - "type": "string" - } - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", + "in": "path", + "name": "id", "schema": { "type": "integer" - } - }, - { - "name": "search", - "required": false, - "in": "query", - "description": "A search term (requested-for, requester name/email).", - "schema": { - "type": "string" - } + }, + "required": true } ], "tags": [ - "Admin requests" + "Admin bookings" ], "security": [ { @@ -53641,13 +54545,14 @@ "responses": { "200": { "content": { - "application/json": { + "application/pdf": { "schema": { - "$ref": "#/components/schemas/PaginatedAdminRequestList" + "type": "string", + "format": "binary" } } }, - "description": "" + "description": "Private answer-free booking pass. Never contains a bearer QR." }, "400": { "content": { @@ -53657,7 +54562,7 @@ } } }, - "description": "Invalid request." + "description": "" }, "401": { "content": { @@ -53667,7 +54572,7 @@ } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { @@ -53677,7 +54582,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -53687,16 +54592,63 @@ } } }, - "description": "Not found." + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/guest-admin/requests/{id}/return": { + "/api/v1/admin/spaces/{id}/arrival/{booking_id}/reverse/": { "post": { - "operationId": "api_v1_guest_admin_requests_return_create", - "summary": "Return issued request items", + "operationId": "api_v1_admin_spaces_arrival_reverse_create", "parameters": [ + { + "in": "path", + "name": "booking_id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "id", @@ -53707,28 +54659,8 @@ } ], "tags": [ - "Admin requests" + "Admin bookings" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReturnRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ReturnRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ReturnRequest" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -53739,7 +54671,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequest" + "$ref": "#/components/schemas/BookingArrival" } } }, @@ -53753,7 +54685,7 @@ } } }, - "description": "Invalid request." + "description": "" }, "401": { "content": { @@ -53763,7 +54695,7 @@ } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { @@ -53773,7 +54705,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -53783,7 +54715,7 @@ } } }, - "description": "Not found." + "description": "" }, "409": { "content": { @@ -53793,9 +54725,9 @@ } } }, - "description": "Workflow conflict." + "description": "" }, - "503": { + "429": { "content": { "application/json": { "schema": { @@ -53803,107 +54735,38 @@ } } }, - "description": "Service unavailable." - } - } - } - }, - "/api/v1/health/": { - "get": { - "operationId": "api_v1_health_retrieve", - "summary": "Health check", - "tags": [ - "Health" - ], - "security": [ - { - "jwtAuth": [] + "description": "" }, - {} - ], - "responses": { - "200": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Health" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, "503": { - "description": "The recovery gate is closed: the database is unreachable or a restore is running." - } - } - } - }, - "/api/v1/health/readiness/": { - "get": { - "operationId": "api_v1_health_readiness_retrieve", - "summary": "Readiness check", - "tags": [ - "Health" - ], - "security": [ - { - "jwtAuth": [] - }, - {} - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Readiness" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" - }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "503": { - "description": "Not ready: host marker, database identity, migrations, fences or PII keys failed." } } } }, - "/api/v1/integrations/makerspace/{makerspace_id}/bounce-webhook": { + "/api/v1/admin/spaces/{id}/arrival/confirm/": { "post": { - "operationId": "api_v1_integrations_makerspace_bounce_webhook_create", - "description": "HMAC-SHA256 hex of ASCII timestamp + dot + raw JSON body. Five-minute replay window. Hard bounces/complaints only.", + "operationId": "api_v1_admin_spaces_arrival_confirm_create", "parameters": [ - { - "in": "header", - "name": "X-SpaceWorks-Signature", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "header", - "name": "X-SpaceWorks-Timestamp", - "schema": { - "type": "string" - }, - "required": true - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -53911,115 +54774,163 @@ } ], "tags": [ - "Notifications" + "Admin bookings" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Bounce" + "$ref": "#/components/schemas/BookingCredential" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/Bounce" + "$ref": "#/components/schemas/BookingCredential" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/Bounce" + "$ref": "#/components/schemas/BookingCredential" } } }, "required": true }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BounceResult" + "$ref": "#/components/schemas/BookingArrival" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "423": { - "description": "Makerspace writes are frozen for tenant migration." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "503": { - "description": "A required dependency is unavailable." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/integrations/makerspace/{makerspace_id}/delivery-logs": { - "get": { - "operationId": "api_v1_integrations_makerspace_delivery_logs_retrieve", + "/api/v1/admin/spaces/{id}/arrival/resolve/": { + "post": { + "operationId": "api_v1_admin_spaces_arrival_resolve_create", "parameters": [ - { - "in": "query", - "name": "channel", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "destination", - "schema": { - "type": "integer" - } - }, - { - "in": "query", - "name": "event", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "feature", - "schema": { - "type": "string" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string" - } } ], "tags": [ - "Notification delivery" + "Admin bookings" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingCredential" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/BookingCredential" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/BookingCredential" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -54030,30 +54941,99 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeliveryPage" + "$ref": "#/components/schemas/BookingArrival" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/integrations/makerspace/{makerspace_id}/delivery-logs/{kind}/{id}/retry": { - "post": { - "operationId": "api_v1_integrations_makerspace_delivery_logs_retry_create", + "/api/v1/admin/spaces/{id}/booking-rules/": { + "get": { + "operationId": "api_v1_admin_spaces_booking_rules_retrieve", + "summary": "Retrieve booking rules for a space", "parameters": [ { "in": "path", @@ -54062,47 +55042,11 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "kind", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Notification delivery" + "Admin bookings" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Retry" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/Retry" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Retry" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -54113,40 +55057,52 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Delivery" + "$ref": "#/components/schemas/BookableSpaceBookingRules" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid booking-rule values or bookings module disabled." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "423": { - "description": "Makerspace writes are frozen for tenant migration." - }, - "503": { - "description": "A required dependency is unavailable." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." } } - } - }, - "/api/v1/integrations/makerspace/{makerspace_id}/delivery-test": { - "post": { - "operationId": "api_v1_integrations_makerspace_delivery_test_create", + }, + "patch": { + "operationId": "api_v1_admin_spaces_booking_rules_partial_update", + "summary": "Update booking rules for a space", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -54154,23 +55110,23 @@ } ], "tags": [ - "Notification delivery" + "Admin bookings" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeliveryTest" + "$ref": "#/components/schemas/PatchedBookableSpaceBookingRules" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/DeliveryTest" + "$ref": "#/components/schemas/PatchedBookableSpaceBookingRules" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/DeliveryTest" + "$ref": "#/components/schemas/PatchedBookableSpaceBookingRules" } } } @@ -54185,48 +55141,131 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeliveryTestResult" + "$ref": "#/components/schemas/BookableSpaceBookingRules" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid booking-rule values or bookings module disabled." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "423": { - "description": "Makerspace writes are frozen for tenant migration." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." }, - "503": { - "description": "A required dependency is unavailable." + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." } } } }, - "/api/v1/integrations/makerspace/{makerspace_id}/email-suppressions": { + "/api/v1/admin/spaces/{id}/bookings/": { "get": { - "operationId": "api_v1_integrations_makerspace_email_suppressions_retrieve", + "operationId": "api_v1_admin_spaces_bookings_retrieve", + "summary": "List bookings for a space", "parameters": [ + { + "in": "query", + "name": "booking_id", + "schema": { + "type": "integer", + "maximum": 9223372036854775807, + "minimum": 1, + "format": "int64" + } + }, + { + "in": "query", + "name": "ends_at", + "schema": { + "type": "string", + "format": "date-time" + } + }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "ordering", + "schema": { + "type": "string" + }, + "description": "Allowlisted sort field; prefix with '-' for descending." + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + }, + "description": "Page number, 24 rows per page." + }, + { + "in": "query", + "name": "starts_at", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "enum": [ + "pending", + "confirmed", + "rejected", + "cancelled", + "completed", + "no_show" + ], + "type": "string", + "minLength": 1 + }, + "description": "* `pending` - Pending\n* `confirmed` - Confirmed\n* `rejected` - Rejected\n* `cancelled` - Cancelled\n* `completed` - Completed\n* `no_show` - No-show" } ], "tags": [ - "Notification delivery" + "Admin bookings" ], "security": [ { @@ -54238,29 +55277,61 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuppressionPage" + "$ref": "#/components/schemas/BookingListResponse" } } }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." + }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." } } }, "post": { - "operationId": "api_v1_integrations_makerspace_email_suppressions_create", + "operationId": "api_v1_admin_spaces_bookings_create", + "summary": "Book a space on behalf of an active member", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -54268,23 +55339,23 @@ } ], "tags": [ - "Notification delivery" + "Admin bookings" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuppressionWrite" + "$ref": "#/components/schemas/StaffBookingInput" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/SuppressionWrite" + "$ref": "#/components/schemas/StaffBookingInput" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/SuppressionWrite" + "$ref": "#/components/schemas/StaffBookingInput" } } }, @@ -54300,36 +55371,90 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Suppression" + "$ref": "#/components/schemas/BookingAdmin" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid input or bookings module disabled." }, "401": { - "description": "Not signed in." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." }, - "423": { - "description": "Makerspace writes are frozen for tenant migration." + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Tenant frozen for migration." }, "503": { - "description": "A required dependency is unavailable." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Tenant write gate unavailable." } } } }, - "/api/v1/integrations/makerspace/{makerspace_id}/email-suppressions/{id}": { - "delete": { - "operationId": "api_v1_integrations_makerspace_email_suppressions_destroy", + "/api/v1/admin/spaces/{id}/broadcasts/": { + "get": { + "operationId": "api_v1_admin_spaces_broadcasts_retrieve", + "description": "Requires live resource manage_reservations authority, bookings.broadcasts, notifications and email. Verified account email only; membership consent, preferences, requester toggle, matrix and quiet hours apply.", "parameters": [ { "in": "path", @@ -54340,59 +55465,15 @@ "required": true }, { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Notification delivery" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "204": { - "description": "Suppression removed after operator review." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "423": { - "description": "Makerspace writes are frozen for tenant migration." - }, - "503": { - "description": "A required dependency is unavailable." - } - } - } - }, - "/api/v1/integrations/makerspace/{makerspace_id}/timing-policy": { - "get": { - "operationId": "api_v1_integrations_makerspace_timing_policy_retrieve", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", + "in": "query", + "name": "page", "schema": { "type": "integer" - }, - "required": true + } } ], "tags": [ - "Notifications" + "Admin bookings" ], "security": [ { @@ -54404,196 +55485,53 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TimingPolicy" + "$ref": "#/components/schemas/BookingBroadcastList" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - } - } - }, - "patch": { - "operationId": "api_v1_integrations_makerspace_timing_policy_partial_update", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Notifications" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedTimingPolicy" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedTimingPolicy" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedTimingPolicy" - } - } - } - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TimingPolicy" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "423": { - "description": "Makerspace writes are frozen for tenant migration." - }, - "503": { - "description": "A required dependency is unavailable." - } - } - } - }, - "/api/v1/integrations/push/devices": { - "post": { - "operationId": "api_v1_integrations_push_devices_create", - "tags": [ - "Native push" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PushDeviceRegistration" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PushDeviceRegistration" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PushDeviceRegistration" - } - } + "description": "" }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PushDevice" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "201": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PushDevice" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, "409": { - "description": "Conflicts with the current state." - }, - "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." - }, - "503": { - "description": "A required dependency is unavailable." - } - } - } - }, - "/api/v1/integrations/push/devices/{device_id}": { - "delete": { - "operationId": "api_v1_integrations_push_devices_destroy", - "parameters": [ - { - "in": "path", - "name": "device_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Native push" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "204": { - "description": "No response body" - }, - "401": { "content": { "application/json": { "schema": { @@ -54603,7 +55541,7 @@ }, "description": "" }, - "403": { + "423": { "content": { "application/json": { "schema": { @@ -54613,7 +55551,7 @@ }, "description": "" }, - "404": { + "429": { "content": { "application/json": { "schema": { @@ -54622,206 +55560,50 @@ } }, "description": "" - } - } - } - }, - "/api/v1/integrations/telegram/test-alert": { - "post": { - "operationId": "api_v1_integrations_telegram_test_alert_create", - "summary": "Send Telegram test alert", - "tags": [ - "Telegram" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TelegramTestAlert" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/TelegramTestAlert" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/TelegramTestAlert" - } - } - }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "description": "Delivery attempt result; `delivered: false` with a reason when Telegram is unconfigured or rejects the message." - }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - } - } - } - }, - "/api/v1/integrations/telegram/webhook": { - "post": { - "operationId": "api_v1_integrations_telegram_webhook_create", - "description": "Retained so an already-registered webhook does not retry forever. Callback queries are acknowledged and discarded: accepting and rejecting borrow requests happens in the staff console, never from chat.", - "summary": "Acknowledge a Telegram webhook (no action is taken)", - "tags": [ - "Telegram" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TelegramWebhook" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/TelegramWebhook" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/TelegramWebhook" - } - } - } - }, - "responses": { - "200": { - "description": "Acknowledged; no action taken." - }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "403": { - "description": "TELEGRAM_WEBHOOK_SECRET is unset, or the secret-token header is wrong." }, - "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." - } - } - } - }, - "/api/v1/internal/cron/return-reminders": { - "post": { - "operationId": "api_v1_internal_cron_return_reminders_create", - "tags": [ - "Health" - ], - "responses": { - "200": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReturnReminderCronResponse" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Return reminder run result." - }, - "403": { - "description": "Invalid cron secret." - }, - "404": { - "description": "Cron endpoint is not configured." - } - } - } - }, - "/api/v1/internal/tls-check": { - "get": { - "operationId": "api_v1_internal_tls_check_retrieve", - "summary": "Check whether on-demand TLS may be issued for a domain", - "parameters": [ - { - "in": "query", - "name": "domain", - "schema": { - "type": "string" - }, - "description": "Canonical hostname requested for on-demand TLS issuance.", - "required": true - } - ], - "tags": [ - "Internal" - ], - "responses": { - "200": { - "description": "TLS issuance is allowed." - }, - "403": { - "description": "TLS issuance is denied." - }, - "400": { - "description": "Managed mode: the Host header is not an infra host." - }, - "503": { - "description": "The recovery gate is closed; deny issuance." + "description": "" } } - } - }, - "/api/v1/kiosk-stations/{public_token}/commands/online/": { + }, "post": { - "operationId": "api_v1_kiosk_stations_commands_online_create", - "summary": "Run one online kiosk command through the workflow", + "operationId": "api_v1_admin_spaces_broadcasts_create", + "description": "Requires live resource manage_reservations authority, bookings.broadcasts, notifications and email. Verified account email only; membership consent, preferences, requester toggle, matrix and quiet hours apply. Compose a draft for a future start window of at most 366 days.", "parameters": [ - { - "in": "header", - "name": "X-Kiosk-Operator-Lease", - "schema": { - "type": "string" - }, - "description": "Signed operator lease returned by the operator-lease route.", - "required": true - }, { "in": "path", - "name": "public_token", + "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Kiosk stations" + "Admin bookings" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KioskOnlineCommand" + "$ref": "#/components/schemas/BookingBroadcastInput" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/KioskOnlineCommand" + "$ref": "#/components/schemas/BookingBroadcastInput" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/KioskOnlineCommand" + "$ref": "#/components/schemas/BookingBroadcastInput" } } }, @@ -54833,11 +55615,11 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KioskOnlineCommandResult" + "$ref": "#/components/schemas/BookingBroadcast" } } }, @@ -54851,7 +55633,7 @@ } } }, - "description": "Invalid payload or capability disabled." + "description": "" }, "401": { "content": { @@ -54861,7 +55643,7 @@ } } }, - "description": "Operator is not signed in." + "description": "" }, "403": { "content": { @@ -54871,7 +55653,7 @@ } } }, - "description": "Invalid Station session, lease or authority." + "description": "" }, "404": { "content": { @@ -54881,7 +55663,27 @@ } } }, - "description": "Target not found in this makerspace." + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "429": { "content": { @@ -54891,7 +55693,7 @@ } } }, - "description": "Request rate limit exceeded." + "description": "" }, "503": { "content": { @@ -54901,57 +55703,45 @@ } } }, - "description": "Storage unavailable." + "description": "" } } } }, - "/api/v1/kiosk-stations/{public_token}/evidence/prepare/": { + "/api/v1/admin/spaces/{id}/broadcasts/{campaign_id}/cancel/": { "post": { - "operationId": "api_v1_kiosk_stations_evidence_prepare_create", - "summary": "Presign a direct-to-staging evidence upload", + "operationId": "api_v1_admin_spaces_broadcasts_cancel_create", + "description": "Requires live resource manage_reservations authority, bookings.broadcasts, notifications and email. Verified account email only; membership consent, preferences, requester toggle, matrix and quiet hours apply. Queue limit: ten per hour per tenant; cancel stops unclaimed sends.", "parameters": [ { - "in": "header", - "name": "X-Kiosk-Operator-Lease", + "in": "path", + "name": "campaign_id", "schema": { - "type": "string" + "type": "integer" }, - "description": "Signed operator lease returned by the operator-lease route.", "required": true }, { "in": "path", - "name": "public_token", + "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Kiosk stations" + "Admin bookings" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KioskEvidencePrepare" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/KioskEvidencePrepare" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/KioskEvidencePrepare" + "type": "object", + "additionalProperties": false } } - }, - "required": true + } }, "security": [ { @@ -54959,11 +55749,11 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EvidenceUrlResponse" + "$ref": "#/components/schemas/BookingBroadcast" } } }, @@ -54977,7 +55767,7 @@ } } }, - "description": "Invalid payload or capability disabled." + "description": "" }, "401": { "content": { @@ -54987,7 +55777,7 @@ } } }, - "description": "Operator is not signed in." + "description": "" }, "403": { "content": { @@ -54997,7 +55787,7 @@ } } }, - "description": "Invalid Station session, lease or authority." + "description": "" }, "404": { "content": { @@ -55007,9 +55797,9 @@ } } }, - "description": "Target not found in this makerspace." + "description": "" }, - "429": { + "409": { "content": { "application/json": { "schema": { @@ -55017,9 +55807,9 @@ } } }, - "description": "Request rate limit exceeded." + "description": "" }, - "503": { + "423": { "content": { "application/json": { "schema": { @@ -55027,46 +55817,83 @@ } } }, - "description": "Storage unavailable." + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/kiosk-stations/{public_token}/operator-lease/": { + "/api/v1/admin/spaces/{id}/broadcasts/{campaign_id}/queue/": { "post": { - "operationId": "api_v1_kiosk_stations_operator_lease_create", - "summary": "Issue a human operator lease on this Station session", + "operationId": "api_v1_admin_spaces_broadcasts_queue_create", + "description": "Requires live resource manage_reservations authority, bookings.broadcasts, notifications and email. Verified account email only; membership consent, preferences, requester toggle, matrix and quiet hours apply. Queue limit: ten per hour per tenant; cancel stops unclaimed sends.", "parameters": [ { "in": "path", - "name": "public_token", + "name": "campaign_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" }, "required": true } ], "tags": [ - "Kiosk stations" + "Admin bookings" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false + } + } + } + }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KioskOperatorLease" + "$ref": "#/components/schemas/BookingBroadcast" } } }, "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { @@ -55074,9 +55901,9 @@ } } }, - "description": "Invalid Station credential, session, origin, feature or lease." + "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -55084,7 +55911,7 @@ } } }, - "description": "Invalid Station credential, session, origin, feature or lease." + "description": "" }, "403": { "content": { @@ -55094,9 +55921,9 @@ } } }, - "description": "Invalid Station credential, session, origin, feature or lease." + "description": "" }, - "429": { + "404": { "content": { "application/json": { "schema": { @@ -55104,46 +55931,29 @@ } } }, - "description": "Invalid Station credential, session, origin, feature or lease." - } - } - }, - "delete": { - "operationId": "api_v1_kiosk_stations_operator_lease_destroy", - "summary": "Revoke the caller's operator lease (operator sign-out)", - "parameters": [ - { - "in": "header", - "name": "X-Kiosk-Operator-Lease", - "schema": { - "type": "string" + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "description": "Signed operator lease returned by the operator-lease route.", - "required": true + "description": "" }, - { - "in": "path", - "name": "public_token", - "schema": { - "type": "string", - "format": "uuid" + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true - } - ], - "tags": [ - "Kiosk stations" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "204": { - "description": "No response body" + "description": "" }, - "401": { + "429": { "content": { "application/json": { "schema": { @@ -55151,9 +55961,9 @@ } } }, - "description": "Invalid Station credential, session, origin, feature or lease." + "description": "" }, - "403": { + "503": { "content": { "application/json": { "schema": { @@ -55161,78 +55971,59 @@ } } }, - "description": "Invalid Station credential, session, origin, feature or lease." + "description": "" } } } }, - "/api/v1/kiosk-stations/{public_token}/replay/{client_command_id}/execute/": { + "/api/v1/admin/spaces/{id}/clone/": { "post": { - "operationId": "api_v1_kiosk_stations_replay_execute_create", - "description": "Same Station cookie, origin/CSRF and human lease as prepare. Current authority and domain rules are checked again; duplicate success returns stable references.", - "summary": "Execute or recover one prepared handover command", + "operationId": "api_v1_admin_spaces_clone_create", + "description": "Requires tenant MANAGE_BOOKINGS and bookings.clone. Copying protected rules also requires MANAGE_MAKERSPACE.", + "summary": "Clone a resource into a private copy", "parameters": [ - { - "in": "header", - "name": "X-Kiosk-Operator-Lease", - "schema": { - "type": "string" - }, - "description": "Signed operator lease returned by the operator-lease route.", - "required": true - }, - { - "in": "header", - "name": "X-Station-CSRF", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "path", - "name": "client_command_id", - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true - }, { "in": "path", - "name": "public_token", + "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Kiosk stations" + "Admin bookings" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReplayEnvelope" + "$ref": "#/components/schemas/BookingCloneInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/BookingCloneInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/BookingCloneInput" } } - }, - "required": true + } }, "security": [ { - "KioskStationCookie": [] - }, - {} + "jwtAuth": [] + } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReplayResult" + "$ref": "#/components/schemas/BookableSpaceAdmin" } } }, @@ -55246,7 +56037,17 @@ } } }, - "description": "Invalid command, target, module or feature disabled." + "description": "Invalid request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." }, "403": { "content": { @@ -55256,9 +56057,9 @@ } } }, - "description": "Station, origin, session, operator lease or live permission refused." + "description": "Permission denied." }, - "409": { + "404": { "content": { "application/json": { "schema": { @@ -55266,9 +56067,9 @@ } } }, - "description": "Ownership conflict, expired command, predecessor refusal or concurrent claim." + "description": "Not found." }, - "423": { + "409": { "content": { "application/json": { "schema": { @@ -55276,7 +56077,7 @@ } } }, - "description": "Tenant migration source gate is closed." + "description": "Workflow conflict." }, "429": { "content": { @@ -55286,7 +56087,7 @@ } } }, - "description": "Replay rate limit exceeded." + "description": "Too many requests." }, "503": { "content": { @@ -55296,69 +56097,39 @@ } } }, - "description": "Storage or replay temporarily unavailable." + "description": "Service unavailable." } } } }, - "/api/v1/kiosk-stations/{public_token}/replay/prepare/": { + "/api/v1/admin/spaces/{id}/deactivate/": { "post": { - "operationId": "api_v1_kiosk_stations_replay_prepare_create", - "description": "Requires the path-bound Station cookie, exact Origin/Referer, X-Station-CSRF and signed human lease; no browser login required. Send the identical envelope to execute.", - "summary": "Prepare an ordered command and staging evidence upload", + "operationId": "api_v1_admin_spaces_deactivate_create", + "summary": "Deactivate a bookable space", "parameters": [ - { - "in": "header", - "name": "X-Kiosk-Operator-Lease", - "schema": { - "type": "string" - }, - "description": "Signed operator lease returned by the operator-lease route.", - "required": true - }, - { - "in": "header", - "name": "X-Station-CSRF", - "schema": { - "type": "string" - }, - "required": true - }, { "in": "path", - "name": "public_token", + "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Kiosk stations" + "Admin bookings" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReplayEnvelope" - } - } - }, - "required": true - }, "security": [ { - "KioskStationCookie": [] - }, - {} + "jwtAuth": [] + } ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReplayResult" + "$ref": "#/components/schemas/BookableSpaceAdmin" } } }, @@ -55372,19 +56143,9 @@ } } }, - "description": "Invalid command, target, module or feature disabled." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Station, origin, session, operator lease or live permission refused." + "description": "Invalid request." }, - "409": { + "401": { "content": { "application/json": { "schema": { @@ -55392,9 +56153,9 @@ } } }, - "description": "Ownership conflict, expired command, predecessor refusal or concurrent claim." + "description": "Authentication required." }, - "423": { + "403": { "content": { "application/json": { "schema": { @@ -55402,9 +56163,9 @@ } } }, - "description": "Tenant migration source gate is closed." + "description": "Permission denied." }, - "429": { + "404": { "content": { "application/json": { "schema": { @@ -55412,9 +56173,9 @@ } } }, - "description": "Replay rate limit exceeded." + "description": "Not found." }, - "503": { + "409": { "content": { "application/json": { "schema": { @@ -55422,61 +56183,63 @@ } } }, - "description": "Storage or replay temporarily unavailable." + "description": "Workflow conflict." } } } }, - "/api/v1/kiosk-stations/{public_token}/session/": { - "post": { - "operationId": "api_v1_kiosk_stations_session_create", - "summary": "Exchange a kiosk Station PIN for a Station session", + "/api/v1/admin/spaces/{id}/eligible-members/": { + "get": { + "operationId": "api_v1_admin_spaces_eligible_members_retrieve", + "summary": "List active members for staff booking", "parameters": [ { "in": "path", - "name": "public_token", + "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } } ], "tags": [ - "Kiosk stations" + "Admin bookings" + ], + "security": [ + { + "jwtAuth": [] + } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KioskStationPin" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/KioskStationPin" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/KioskStationPin" - } - } - }, - "required": true - }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KioskStationBootstrap" + "$ref": "#/components/schemas/BookingEligibleMemberList" } } }, "description": "" }, "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid input or bookings module disabled." + }, + "401": { "content": { "application/json": { "schema": { @@ -55484,7 +56247,7 @@ } } }, - "description": "Invalid Station credential, session, origin, feature or lease." + "description": "Authentication required." }, "403": { "content": { @@ -55494,9 +56257,9 @@ } } }, - "description": "Invalid Station credential, session, origin, feature or lease." + "description": "Permission denied." }, - "429": { + "404": { "content": { "application/json": { "schema": { @@ -55504,36 +56267,57 @@ } } }, - "description": "Invalid Station credential, session, origin, feature or lease." + "description": "Not found." } } - }, + } + }, + "/api/v1/admin/spaces/{id}/image/": { "delete": { - "operationId": "api_v1_kiosk_stations_session_destroy", - "summary": "End the Station session and revoke its operator leases", + "operationId": "api_v1_admin_spaces_image_destroy", + "summary": "Delete a space image", "parameters": [ { "in": "path", - "name": "public_token", + "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Kiosk stations" + "Admin bookings" ], "security": [ { - "KioskStationCookie": [] + "jwtAuth": [] } ], "responses": { "204": { "description": "No response body" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, "403": { "content": { "application/json": { @@ -55542,9 +56326,9 @@ } } }, - "description": "Invalid Station credential, session, origin, feature or lease." + "description": "Permission denied." }, - "429": { + "404": { "content": { "application/json": { "schema": { @@ -55552,53 +56336,53 @@ } } }, - "description": "Invalid Station credential, session, origin, feature or lease." + "description": "Not found." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Service unavailable." } } } }, - "/api/v1/kiosk-stations/{public_token}/work-package/": { + "/api/v1/admin/spaces/{id}/image/finalize/": { "post": { - "operationId": "api_v1_kiosk_stations_work_package_create", - "summary": "Load the minimal work package for one handover", + "operationId": "api_v1_admin_spaces_image_finalize_create", + "summary": "Finalize and attach a space image", "parameters": [ - { - "in": "header", - "name": "X-Kiosk-Operator-Lease", - "schema": { - "type": "string" - }, - "description": "Signed operator lease returned by the operator-lease route.", - "required": true - }, { "in": "path", - "name": "public_token", + "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Kiosk stations" + "Admin bookings" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KioskWorkPackageRequest" + "$ref": "#/components/schemas/SpaceImageFinalizeRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/KioskWorkPackageRequest" + "$ref": "#/components/schemas/SpaceImageFinalizeRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/KioskWorkPackageRequest" + "$ref": "#/components/schemas/SpaceImageFinalizeRequest" } } }, @@ -55614,7 +56398,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KioskWorkPackage" + "$ref": "#/components/schemas/BookableSpaceAdmin" } } }, @@ -55628,7 +56412,7 @@ } } }, - "description": "Invalid payload or capability disabled." + "description": "Invalid request." }, "401": { "content": { @@ -55638,7 +56422,7 @@ } } }, - "description": "Operator is not signed in." + "description": "Authentication required." }, "403": { "content": { @@ -55648,7 +56432,7 @@ } } }, - "description": "Invalid Station session, lease or authority." + "description": "Permission denied." }, "404": { "content": { @@ -55658,9 +56442,9 @@ } } }, - "description": "Target not found in this makerspace." + "description": "Not found." }, - "429": { + "503": { "content": { "application/json": { "schema": { @@ -55668,75 +56452,85 @@ } } }, - "description": "Request rate limit exceeded." + "description": "Service unavailable." } } } }, - "/api/v1/live/": { - "get": { - "operationId": "api_v1_live_retrieve", - "description": "text/event-stream of `change` events `{kind, makerspace_id, target_type, target_id, actor_id, ts}` for every makerspace the session may see. Clients invalidate cached queries on an event and refetch; no tenant content travels on the stream. 503 when the deployment has no Redis.", - "summary": "Live update stream (Server-Sent Events)", + "/api/v1/admin/spaces/{id}/image/presign/": { + "post": { + "operationId": "api_v1_admin_spaces_image_presign_create", + "summary": "Create a space image upload URL", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Health" + "Admin bookings" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SpaceImagePresignRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/SpaceImagePresignRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/SpaceImagePresignRequest" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { - "text/event-stream": { + "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/SpaceImagePresignResponse" } } }, "description": "" }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Account access is restricted." + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." }, - "503": { - "description": "No usable Redis (unset, misconfigured or unreachable); poll instead." - } - } - } - }, - "/api/v1/member/archived-payments": { - "get": { - "operationId": "api_v1_member_archived_payments_list", - "description": "Lists archived makerspaces where the caller retains an active membership and has payment history available to read or settle.", - "summary": "Discover the caller's archived makerspace payments", - "tags": [ - "Payments" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "401": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ArchivedPaymentSummary" - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Authentication required." }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -55744,18 +56538,39 @@ } } }, - "description": "" + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Service unavailable." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/account/cancel": { + "/api/v1/admin/spaces/{id}/pass-preview/": { "post": { - "operationId": "api_v1_member_makerspaces_account_cancel_create", + "operationId": "api_v1_admin_spaces_pass_preview_create", + "description": "Requires edit_details. Fixed sample recipient only; no booking answers, notes, contacts or QR.", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -55763,27 +56578,26 @@ } ], "tags": [ - "Member account" + "Admin bookings" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipReason" + "$ref": "#/components/schemas/BookingPassTemplate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MembershipReason" + "$ref": "#/components/schemas/BookingPassTemplate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MembershipReason" + "$ref": "#/components/schemas/BookingPassTemplate" } } - }, - "required": true + } }, "security": [ { @@ -55793,42 +56607,30 @@ "responses": { "200": { "content": { - "application/json": { + "application/pdf": { "schema": { - "$ref": "#/components/schemas/MembershipAccount" + "type": "string", + "format": "binary" } } }, - "description": "" + "description": "Private answer-free booking pass. Never contains a bearer QR." }, "400": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Disabled module/feature, invalid input, or unavailable export." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -55838,7 +56640,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -55848,17 +56650,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "423": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -55868,7 +56670,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -55878,7 +56690,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -55887,14 +56699,14 @@ } } }, - "/api/v1/member/makerspaces/{makerspace_id}/account/exports": { + "/api/v1/admin/spaces/{id}/pass-template/": { "get": { - "operationId": "api_v1_member_makerspaces_account_exports_retrieve", - "summary": "List the caller's personal exports", + "operationId": "api_v1_admin_spaces_pass_template_retrieve", + "description": "Requires live resource edit_details and bookings.passes. Answer-free fields only.", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -55902,7 +56714,7 @@ } ], "tags": [ - "Member account" + "Admin bookings" ], "security": [ { @@ -55914,7 +56726,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SubjectExportList" + "$ref": "#/components/schemas/BookingPassTemplate" } } }, @@ -55924,30 +56736,17 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Disabled module/feature, invalid input, or unavailable export." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -55957,7 +56756,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -55967,17 +56766,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "423": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -55987,7 +56786,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -55997,7 +56806,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -56005,13 +56814,12 @@ } } }, - "post": { - "operationId": "api_v1_member_makerspaces_account_exports_create", - "summary": "Request a personal ZIP/JSON data export", + "put": { + "operationId": "api_v1_admin_spaces_pass_template_update", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -56019,14 +56827,23 @@ } ], "tags": [ - "Member account" + "Admin bookings" ], "requestBody": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": false + "$ref": "#/components/schemas/BookingPassTemplate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/BookingPassTemplate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/BookingPassTemplate" } } } @@ -56037,11 +56854,11 @@ } ], "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SubjectExport" + "$ref": "#/components/schemas/BookingPassTemplate" } } }, @@ -56051,30 +56868,17 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Disabled module/feature, invalid input, or unavailable export." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -56084,7 +56888,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -56094,17 +56898,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "423": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -56114,7 +56918,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -56124,7 +56938,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -56133,31 +56947,106 @@ } } }, - "/api/v1/member/makerspaces/{makerspace_id}/account/exports/{job_id}": { - "get": { - "operationId": "api_v1_member_makerspaces_account_exports_retrieve_2", - "summary": "Poll the caller's personal export", + "/api/v1/admin/spaces/{id}/recurring-bookings/": { + "post": { + "operationId": "api_v1_admin_spaces_recurring_bookings_create", + "description": "Create a finite weekly series in makerspace local time. Each occurrence has its own flat charge and cancellation. Conflicts are reported, never silently skipped.", "parameters": [ { "in": "path", - "name": "job_id", + "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true + } + ], + "tags": [ + "Admin bookings" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecurringBookingInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/RecurringBookingInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/RecurringBookingInput" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecurrenceResult" + } + } + }, + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." }, + "409": { + "description": "Conflicts with the current state." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." + }, + "503": { + "description": "A required dependency is unavailable." + } + } + } + }, + "/api/v1/admin/spaces/{id}/staff-grants/": { + "get": { + "operationId": "api_v1_admin_spaces_staff_grants_retrieve", + "description": "Requires live tenant MANAGE_BOOKINGS and bookings.staff_grants. Resource grantees cannot delegate. Invitations expire after seven days; sender authority and permission subset are rechecked on redemption.", + "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } } ], "tags": [ - "Member account" + "Admin bookings" ], "security": [ { @@ -56169,60 +57058,47 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SubjectExport" + "$ref": "#/components/schemas/BookingStaffGrantList" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Disabled module/feature, invalid input, or unavailable export." + "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "404": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -56232,7 +57108,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -56242,7 +57118,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -56252,40 +57128,49 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Field validation, module or feature disabled." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/account/exports/{job_id}/download": { - "post": { - "operationId": "api_v1_member_makerspaces_account_exports_download_create", - "summary": "Sign a short-lived personal export download URL", + "/api/v1/admin/spaces/{id}/staff-invites/": { + "get": { + "operationId": "api_v1_admin_spaces_staff_invites_retrieve", + "description": "Requires live tenant MANAGE_BOOKINGS and bookings.staff_grants. Resource grantees cannot delegate. Invitations expire after seven days; sender authority and permission subset are rechecked on redemption.", "parameters": [ { "in": "path", - "name": "job_id", + "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true }, { - "in": "path", - "name": "makerspace_id", + "in": "query", + "name": "page", "schema": { "type": "integer" - }, - "required": true + } } ], "tags": [ - "Member account" + "Admin bookings" ], "security": [ { @@ -56297,105 +57182,102 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DataExportDownloadUrl" + "$ref": "#/components/schemas/BookingStaffGrantInviteList" } } }, "description": "" }, - "503": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "400": { + "403": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Disabled module/feature, invalid input, or unavailable export." + "description": "" }, - "401": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "403": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "404": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "423": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "429": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Field validation, module or feature disabled." } } - } - }, - "/api/v1/member/makerspaces/{makerspace_id}/account/leave": { + }, "post": { - "operationId": "api_v1_member_makerspaces_account_leave_create", + "operationId": "api_v1_admin_spaces_staff_invites_create", + "description": "Requires live tenant MANAGE_BOOKINGS and bookings.staff_grants. Resource grantees cannot delegate. Invitations expire after seven days; sender authority and permission subset are rechecked on redemption.", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -56403,23 +57285,23 @@ } ], "tags": [ - "Member account" + "Admin bookings" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipReason" + "$ref": "#/components/schemas/BookingStaffGrantInviteInput" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MembershipReason" + "$ref": "#/components/schemas/BookingStaffGrantInviteInput" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MembershipReason" + "$ref": "#/components/schemas/BookingStaffGrantInviteInput" } } }, @@ -56431,64 +57313,51 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipAccount" + "$ref": "#/components/schemas/BookingStaffGrantInviteCreated" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Disabled module/feature, invalid input, or unavailable export." + "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "404": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -56498,7 +57367,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -56508,7 +57377,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -56518,22 +57387,33 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Field validation, module or feature disabled." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/account/membership": { + "/api/v1/admin/spaces/{id}/website/": { "get": { - "operationId": "api_v1_member_makerspaces_account_membership_retrieve", + "operationId": "api_v1_admin_spaces_website_retrieve", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -56541,7 +57421,7 @@ } ], "tags": [ - "Member account" + "Booking website" ], "security": [ { @@ -56553,7 +57433,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipAccount" + "$ref": "#/components/schemas/BookingWebsite" } } }, @@ -56563,95 +57443,80 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Disabled module/feature, invalid input, or unavailable export." + "description": "Invalid content, stale revision or disabled feature." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Sign in required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Missing resource permission or wrong origin." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Unknown or out-of-scope resource/image." }, - "423": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Rate limit exceeded." }, - "429": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Tenant frozen." }, "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Storage or write gate unavailable." } } - } - }, - "/api/v1/member/makerspaces/{makerspace_id}/account/profile": { - "get": { - "operationId": "api_v1_member_makerspaces_account_profile_retrieve", + }, + "put": { + "operationId": "api_v1_admin_spaces_website_update", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -56659,8 +57524,28 @@ } ], "tags": [ - "Member account" + "Booking website" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingWebsiteContentInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/BookingWebsiteContentInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/BookingWebsiteContentInput" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -56671,7 +57556,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountProfile" + "$ref": "#/components/schemas/BookingWebsite" } } }, @@ -56681,93 +57566,82 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Disabled module/feature, invalid input, or unavailable export." + "description": "Invalid content, stale revision or disabled feature." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Sign in required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Missing resource permission or wrong origin." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Unknown or out-of-scope resource/image." }, - "423": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Rate limit exceeded." }, - "429": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Tenant frozen." }, "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Storage or write gate unavailable." } } - }, - "patch": { - "operationId": "api_v1_member_makerspaces_account_profile_partial_update", + } + }, + "/api/v1/admin/spaces/{id}/website/assets/": { + "post": { + "operationId": "api_v1_admin_spaces_website_assets_create", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -56775,26 +57649,17 @@ } ], "tags": [ - "Member account" + "Booking website" ], "requestBody": { "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedAccountProfile" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedAccountProfile" - } - }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedAccountProfile" + "$ref": "#/components/schemas/BookingWebsiteAssetInput" } } - } + }, + "required": true }, "security": [ { @@ -56802,11 +57667,11 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountProfile" + "$ref": "#/components/schemas/BookingWebsiteAsset" } } }, @@ -56816,95 +57681,90 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Disabled module/feature, invalid input, or unavailable export." + "description": "Invalid content, stale revision or disabled feature." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Sign in required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Missing resource permission or wrong origin." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Unknown or out-of-scope resource/image." }, - "423": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Rate limit exceeded." }, - "429": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Tenant frozen." }, "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Storage or write gate unavailable." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/account/sessions": { + "/api/v1/admin/spaces/{id}/website/assets/{asset_id}/": { "get": { - "operationId": "api_v1_member_makerspaces_account_sessions_retrieve", + "operationId": "api_v1_admin_spaces_website_assets_retrieve", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "asset_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "id", "schema": { "type": "integer" }, @@ -56912,7 +57772,7 @@ } ], "tags": [ - "Member account" + "Booking website" ], "security": [ { @@ -56922,107 +57782,93 @@ "responses": { "200": { "content": { - "application/json": { + "image/webp": { "schema": { - "$ref": "#/components/schemas/AccountSessionPage" + "type": "string", + "format": "binary" } } }, - "description": "" + "description": "Private no-store WebP preview." }, "400": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Disabled module/feature, invalid input, or unavailable export." + "description": "Invalid content, stale revision or disabled feature." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Sign in required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Missing resource permission or wrong origin." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Unknown or out-of-scope resource/image." }, - "423": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Rate limit exceeded." }, - "429": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Tenant frozen." }, "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Storage or write gate unavailable." } } - } - }, - "/api/v1/member/makerspaces/{makerspace_id}/account/sessions/{session_id}": { + }, "delete": { - "operationId": "api_v1_member_makerspaces_account_sessions_destroy", + "operationId": "api_v1_admin_spaces_website_assets_destroy", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "asset_id", "schema": { "type": "integer" }, @@ -57030,15 +57876,15 @@ }, { "in": "path", - "name": "session_id", + "name": "id", "schema": { - "type": "string" + "type": "integer" }, "required": true } ], "tags": [ - "Member account" + "Booking website" ], "security": [ { @@ -57053,96 +57899,82 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Disabled module/feature, invalid input, or unavailable export." + "description": "Invalid content, stale revision or disabled feature." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Sign in required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Missing resource permission or wrong origin." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Unknown or out-of-scope resource/image." }, - "423": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Rate limit exceeded." }, - "429": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Tenant frozen." }, "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFailure" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Storage or write gate unavailable." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/activity": { - "get": { - "operationId": "api_v1_member_makerspaces_activity_retrieve", - "summary": "Retrieve the caller's makerspace activity", + "/api/v1/admin/spaces/{id}/website/release/": { + "post": { + "operationId": "api_v1_admin_spaces_website_release_create", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -57150,76 +57982,28 @@ } ], "tags": [ - "Member activity" + "Booking website" ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MemberActivity" - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingWebsiteReleaseInput" } }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/BookingWebsiteReleaseInput" } }, - "description": "Membership module disabled." - }, - "401": { - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/BookingWebsiteReleaseInput" } - }, - "description": "An active membership is required." + } }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Makerspace not found." - } - } - } - }, - "/api/v1/member/makerspaces/{makerspace_id}/admission": { - "get": { - "operationId": "api_v1_member_makerspaces_admission_retrieve", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Memberships" - ], + "required": true + }, "security": [ { "jwtAuth": [] @@ -57230,7 +58014,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdmissionRequest" + "$ref": "#/components/schemas/BookingWebsite" } } }, @@ -57244,7 +58028,7 @@ } } }, - "description": "" + "description": "Invalid content, stale revision or disabled feature." }, "401": { "content": { @@ -57254,7 +58038,7 @@ } } }, - "description": "" + "description": "Sign in required." }, "403": { "content": { @@ -57264,7 +58048,7 @@ } } }, - "description": "" + "description": "Missing resource permission or wrong origin." }, "404": { "content": { @@ -57274,9 +58058,9 @@ } } }, - "description": "" + "description": "Unknown or out-of-scope resource/image." }, - "423": { + "429": { "content": { "application/json": { "schema": { @@ -57284,9 +58068,9 @@ } } }, - "description": "" + "description": "Rate limit exceeded." }, - "429": { + "423": { "content": { "application/json": { "schema": { @@ -57294,7 +58078,7 @@ } } }, - "description": "" + "description": "Tenant frozen." }, "503": { "content": { @@ -57304,26 +58088,19 @@ } } }, - "description": "" + "description": "Storage or write gate unavailable." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/admission/{request_id}/cancel": { - "post": { - "operationId": "api_v1_member_makerspaces_admission_cancel_create", + "/api/v1/admin/stock-transfers/{id}": { + "get": { + "operationId": "api_v1_admin_stock_transfers_retrieve", + "summary": "Retrieve stock transfer", "parameters": [ { "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "path", - "name": "request_id", + "name": "id", "schema": { "type": "integer" }, @@ -57331,7 +58108,7 @@ } ], "tags": [ - "Memberships" + "Stock transfers" ], "security": [ { @@ -57343,7 +58120,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdmissionRequest" + "$ref": "#/components/schemas/StockTransfer" } } }, @@ -57353,90 +58130,87 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/GenericObject" } } }, - "description": "" + "description": "Invalid request." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Authentication credentials were not provided." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Permission denied." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } + "description": "Not found." + } + } + } + }, + "/api/v1/admin/stocktakes/{id}": { + "get": { + "operationId": "api_v1_admin_stocktakes_retrieve", + "summary": "Retrieve stocktake", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" }, - "description": "" - }, - "423": { + "required": true + } + ], + "tags": [ + "Stocktake" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/Stocktake" } } }, "description": "" }, - "429": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/GenericObject" } } }, - "description": "" + "description": "Invalid request or stocktake state." }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/admission/{request_id}/vouch": { + "/api/v1/admin/stocktakes/{id}/apply-adjustments": { "post": { - "operationId": "api_v1_member_makerspaces_admission_vouch_create", + "operationId": "api_v1_admin_stocktakes_apply_adjustments_create", + "summary": "Apply stocktake adjustments", "parameters": [ { "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "path", - "name": "request_id", + "name": "id", "schema": { "type": "integer" }, @@ -57444,7 +58218,7 @@ } ], "tags": [ - "Memberships" + "Stocktake" ], "security": [ { @@ -57456,7 +58230,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdmissionVouchOutcome" + "$ref": "#/components/schemas/Stocktake" } } }, @@ -57466,82 +58240,32 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/GenericObject" } } }, - "description": "" + "description": "Invalid request or stocktake state." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Authentication credentials were not provided." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Permission denied." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not found." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/admission/export": { - "get": { - "operationId": "api_v1_member_makerspaces_admission_export_retrieve", + "/api/v1/admin/stocktakes/{id}/approve": { + "post": { + "operationId": "api_v1_admin_stocktakes_approve_create", + "summary": "Approve stocktake", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -57549,7 +58273,7 @@ } ], "tags": [ - "Memberships" + "Stocktake" ], "security": [ { @@ -57561,7 +58285,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdmissionExport" + "$ref": "#/components/schemas/Stocktake" } } }, @@ -57571,99 +58295,40 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/GenericObject" } } }, - "description": "" + "description": "Invalid request or stocktake state." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Authentication credentials were not provided." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Permission denied." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not found." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/certifications": { - "get": { - "operationId": "api_v1_member_makerspaces_certifications_retrieve", - "summary": "List your own certification history", + "/api/v1/admin/stocktakes/{id}/complete": { + "post": { + "operationId": "api_v1_admin_stocktakes_complete_create", + "summary": "Complete stocktake", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer" - }, - "description": "Page number (24 rows per page)." } ], "tags": [ - "Member certifications" + "Stocktake" ], "security": [ { @@ -57675,35 +58340,42 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberCertificationPage" + "$ref": "#/components/schemas/Stocktake" } } }, "description": "" }, "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenericObject" + } + } + }, + "description": "Invalid request or stocktake state." }, "401": { - "description": "Not signed in." + "description": "Authentication credentials were not provided." }, "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + "description": "Permission denied." }, "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "description": "Not found." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/collaborative-events/": { - "get": { - "operationId": "api_v1_member_makerspaces_collaborative_events_list", - "summary": "List events hosted by accepted collaborators", + "/api/v1/admin/stocktakes/{id}/count-lines": { + "post": { + "operationId": "api_v1_admin_stocktakes_count_lines_create", + "summary": "Count stocktake line", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -57711,22 +58383,39 @@ } ], "tags": [ - "Member events" + "Stocktake" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StocktakeLineInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/StocktakeLineInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/StocktakeLineInput" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CollaborativeEvent" - } + "$ref": "#/components/schemas/StocktakeLine" } } }, @@ -57736,39 +58425,28 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/GenericObject" } } }, - "description": "Invalid event request." + "description": "Invalid request or stocktake state." + }, + "401": { + "description": "Authentication credentials were not provided." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Active membership is required." + "description": "Permission denied." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Collaborative event not found." + "description": "Not found." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/collaborative-events/{id}/register/": { + "/api/v1/admin/stocktakes/{id}/resolve-scan": { "post": { - "operationId": "api_v1_member_makerspaces_collaborative_events_register_create", - "summary": "Register for a collaborative event", + "operationId": "api_v1_admin_stocktakes_resolve_scan_create", + "summary": "Resolve a scanned QR to a stocktake count target", "parameters": [ { "in": "path", @@ -57777,37 +58455,30 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Member events" + "Stocktake" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollaborativeEventRegistrationInput" + "$ref": "#/components/schemas/StocktakeScanInput" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/CollaborativeEventRegistrationInput" + "$ref": "#/components/schemas/StocktakeScanInput" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/CollaborativeEventRegistrationInput" + "$ref": "#/components/schemas/StocktakeScanInput" } } - } + }, + "required": true }, "security": [ { @@ -57815,11 +58486,11 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicEventRegistrationResponse" + "$ref": "#/components/schemas/StocktakeScanResult" } } }, @@ -57829,79 +58500,95 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/GenericObject" } } }, - "description": "Invalid event request." + "description": "Invalid request or stocktake state." + }, + "401": { + "description": "Authentication credentials were not provided." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Active membership is required." + "description": "Permission denied." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "description": "Not found." + } + } + } + }, + "/api/v1/admin/users/{id}/reset-password": { + "post": { + "operationId": "api_v1_admin_users_reset_password_create", + "summary": "Reset a staff user's password (temp + force change)", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin users" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordRequest" } }, - "description": "Collaborative event not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordRequest" } }, - "description": "Event state conflict." - }, - "429": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordRequest" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ResetPasswordResponse" } } }, - "description": "Rate limit exceeded." + "description": "" } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/directory": { - "get": { - "operationId": "api_v1_member_makerspaces_directory_retrieve", - "summary": "List members who published a profile", + "/api/v1/admin/users/{id}/restore-access": { + "post": { + "operationId": "api_v1_admin_users_restore_access_create", + "summary": "Restore user access", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "q", - "schema": { - "type": "string" - }, - "description": "Match on username, display name, headline or institution. Contact details are never searched." } ], "tags": [ - "Member profile" + "Admin users" ], "security": [ { @@ -57913,62 +58600,97 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Directory" + "$ref": "#/components/schemas/User" } } }, "description": "" - }, - "401": { - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + } + } + } + }, + "/api/v1/admin/users/{id}/restrict": { + "post": { + "operationId": "api_v1_admin_users_restrict_create", + "summary": "Restrict or suspend a user", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin users" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestrictUser" + }, + "examples": { + "RestrictARequester": { + "value": { + "status": "restricted", + "reason": "Unreturned loan under review" + }, + "summary": "Restrict a requester" } } }, - "description": "An active membership is required." + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/RestrictUser" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/RestrictUser" + } + } }, - "404": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/User" } } }, - "description": "Not found." + "description": "" } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/directory/{membership_id}": { + "/api/v1/admin/users/booking-managers": { "get": { - "operationId": "api_v1_member_makerspaces_directory_retrieve_2", - "summary": "Retrieve another member's published profile", + "operationId": "api_v1_admin_users_booking_managers_list", + "summary": "List or create staff memberships", "parameters": [ { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "path", - "name": "membership_id", + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", "schema": { "type": "integer" - }, - "required": true + } } ], "tags": [ - "Member profile" + "Admin users" ], "security": [ { @@ -57980,53 +58702,76 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileRead" + "$ref": "#/components/schemas/PaginatedStaffMembershipList" } } }, "description": "" - }, - "401": { - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + } + } + }, + "post": { + "operationId": "api_v1_admin_users_booking_managers_create", + "summary": "List or create staff memberships", + "tags": [ + "Admin users" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StaffMembership" } }, - "description": "An active membership is required." + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/StaffMembership" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/StaffMembership" + } + } }, - "404": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/StaffMembership" } } }, - "description": "Not found." + "description": "" } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/event-calendar-feed/": { + "/api/v1/admin/users/event-managers": { "get": { - "operationId": "api_v1_member_makerspaces_event_calendar_feed_retrieve", + "operationId": "api_v1_admin_users_event_managers_list", + "summary": "List or create staff memberships", "parameters": [ { - "in": "path", - "name": "makerspace_id", + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", "schema": { "type": "integer" - }, - "required": true + } } ], "tags": [ - "Member events" + "Admin users" ], "security": [ { @@ -58038,84 +58783,197 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberCalendarFeedState" - } - } - }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaginatedStaffMembershipList" } } }, "description": "" + } + } + }, + "post": { + "operationId": "api_v1_admin_users_event_managers_create", + "summary": "List or create staff memberships", + "tags": [ + "Admin users" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StaffMembership" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/StaffMembership" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/StaffMembership" + } + } }, - "403": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/StaffMembership" } } }, "description": "" - }, - "404": { + } + } + } + }, + "/api/v1/admin/users/handover": { + "get": { + "operationId": "api_v1_admin_users_handover_list", + "summary": "List or create staff memberships", + "parameters": [ + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + } + ], + "tags": [ + "Admin users" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaginatedStaffMembershipList" } } }, "description": "" + } + } + }, + "post": { + "operationId": "api_v1_admin_users_handover_create", + "summary": "List or create staff memberships", + "tags": [ + "Admin users" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StaffMembership" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/StaffMembership" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/StaffMembership" + } + } }, - "400": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/StaffMembership" } } }, "description": "" } } - }, - "post": { - "operationId": "api_v1_member_makerspaces_event_calendar_feed_create", + } + }, + "/api/v1/admin/users/inventory-managers": { + "get": { + "operationId": "api_v1_admin_users_inventory_managers_list", + "summary": "List or create staff memberships", "parameters": [ { - "in": "path", - "name": "makerspace_id", + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", "schema": { "type": "integer" - }, - "required": true + } } ], "tags": [ - "Member events" + "Admin users" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedStaffMembershipList" + } + } + }, + "description": "" + } + } + }, + "post": { + "operationId": "api_v1_admin_users_inventory_managers_create", + "summary": "List or create staff memberships", + "tags": [ + "Admin users" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberCalendarFeedIssue" + "$ref": "#/components/schemas/StaffMembership" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MemberCalendarFeedIssue" + "$ref": "#/components/schemas/StaffMembership" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MemberCalendarFeedIssue" + "$ref": "#/components/schemas/StaffMembership" } } }, @@ -58127,74 +58985,189 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberCalendarFeedIssued" + "$ref": "#/components/schemas/StaffMembership" } } }, "description": "" - }, - "400": { + } + } + } + }, + "/api/v1/admin/users/machine-managers": { + "get": { + "operationId": "api_v1_admin_users_machine_managers_list", + "summary": "List or create staff memberships", + "parameters": [ + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + } + ], + "tags": [ + "Admin users" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaginatedStaffMembershipList" } } }, "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + } + } + }, + "post": { + "operationId": "api_v1_admin_users_machine_managers_create", + "summary": "List or create staff memberships", + "tags": [ + "Admin users" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StaffMembership" } }, - "description": "" + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/StaffMembership" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/StaffMembership" + } + } }, - "403": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/StaffMembership" } } }, "description": "" - }, - "404": { + } + } + } + }, + "/api/v1/admin/users/space-managers": { + "get": { + "operationId": "api_v1_admin_users_space_managers_list", + "summary": "List or create staff memberships", + "parameters": [ + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + } + ], + "tags": [ + "Admin users" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaginatedStaffMembershipList" } } }, "description": "" + } + } + }, + "post": { + "operationId": "api_v1_admin_users_space_managers_create", + "summary": "List or create staff memberships", + "tags": [ + "Admin users" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StaffMembership" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/StaffMembership" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/StaffMembership" + } + } }, - "409": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/StaffMembership" } } }, - "description": "Concurrent rotation conflict." + "description": "" } } - }, - "delete": { - "operationId": "api_v1_member_makerspaces_event_calendar_feed_destroy", + } + }, + "/api/v1/admin/warranty/{id}/documents": { + "get": { + "operationId": "api_v1_admin_warranty_documents_list", + "summary": "List documents attached to a warranty", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -58202,7 +59175,7 @@ } ], "tags": [ - "Member events" + "Admin warranty" ], "security": [ { @@ -58210,20 +59183,20 @@ } ], "responses": { - "204": { - "description": "No response body" - }, - "401": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "array", + "items": { + "$ref": "#/components/schemas/WarrantyDocument" + } } } }, "description": "" }, - "403": { + "401": { "content": { "application/json": { "schema": { @@ -58233,7 +59206,7 @@ }, "description": "" }, - "404": { + "403": { "content": { "application/json": { "schema": { @@ -58243,7 +59216,7 @@ }, "description": "" }, - "400": { + "404": { "content": { "application/json": { "schema": { @@ -58254,11 +59227,10 @@ "description": "" } } - } - }, - "/api/v1/member/makerspaces/{makerspace_id}/event-certificates/{id}/download/": { - "get": { - "operationId": "api_v1_member_makerspaces_event_certificates_download_retrieve", + }, + "post": { + "operationId": "api_v1_admin_warranty_documents_create", + "summary": "Finalize an uploaded warranty document", "parameters": [ { "in": "path", @@ -58267,36 +59239,51 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Member events" + "Admin warranty" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WarrantyDocumentFinalize" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/WarrantyDocumentFinalize" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/WarrantyDocumentFinalize" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CertificateDownload" + "$ref": "#/components/schemas/WarrantyDocument" } } }, "description": "" }, - "410": { + "400": { + "description": "Invalid or duplicate warranty document." + }, + "401": { "content": { "application/json": { "schema": { @@ -58304,9 +59291,9 @@ } } }, - "description": "Certificate revoked." + "description": "" }, - "503": { + "403": { "content": { "application/json": { "schema": { @@ -58314,9 +59301,9 @@ } } }, - "description": "Certificate storage unavailable." + "description": "" }, - "400": { + "404": { "content": { "application/json": { "schema": { @@ -58324,19 +59311,71 @@ } } }, - "description": "Invalid feedback answers." + "description": "" }, - "403": { + "503": { + "description": "Warranty document storage is unavailable." + } + } + } + }, + "/api/v1/admin/warranty/{id}/documents/presign": { + "post": { + "operationId": "api_v1_admin_warranty_documents_presign_create", + "summary": "Create a warranty document upload URL", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin warranty" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WarrantyDocumentPresign" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/WarrantyDocumentPresign" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/WarrantyDocumentPresign" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/WarrantyDocumentUploadResponse" } } }, - "description": "Active membership is required." + "description": "" }, - "404": { + "400": { + "description": "Invalid document upload request." + }, + "401": { "content": { "application/json": { "schema": { @@ -58344,9 +59383,9 @@ } } }, - "description": "Feedback resource not found." + "description": "" }, - "409": { + "403": { "content": { "application/json": { "schema": { @@ -58354,9 +59393,9 @@ } } }, - "description": "Feedback or certificate state conflict." + "description": "" }, - "429": { + "404": { "content": { "application/json": { "schema": { @@ -58364,16 +59403,18 @@ } } }, - "description": "Rate limit exceeded." + "description": "" + }, + "503": { + "description": "Warranty document storage is unavailable." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/event-registrations/{id}/cancel/": { - "post": { - "operationId": "api_v1_member_makerspaces_event_registrations_cancel_create", - "description": "Releases pending charges and uses the event's existing waitlist promotion policy. Settled charges are retained; this action does not issue a refund. Members may cancel only before the event's cancellation cutoff (null means start).", - "summary": "Cancel the caller's event registration", + "/api/v1/admin/warranty/documents/{id}": { + "delete": { + "operationId": "api_v1_admin_warranty_documents_destroy", + "summary": "Delete a warranty document", "parameters": [ { "in": "path", @@ -58382,18 +59423,10 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Member events" + "Admin warranty" ], "security": [ { @@ -58401,28 +59434,30 @@ } ], "responses": { - "200": { + "204": { + "description": "No response body" + }, + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberRegistrationCancelResponse" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "400": { + "403": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid body or events module disabled (field-keyed errors)." + "description": "" }, - "401": { + "404": { "content": { "application/json": { "schema": { @@ -58430,74 +59465,15 @@ } } }, - "description": "Authentication is required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "An active account owning an account/invite registration, or an active membership, is required." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Owned registration not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Registration cannot be cancelled." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Rate limit exceeded." - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Tenant frozen for migration." - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Tenant write gate unavailable." + "description": "" } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/event-registrations/{id}/feedback/": { + "/api/v1/admin/warranty/documents/{id}/url": { "get": { - "operationId": "api_v1_member_makerspaces_event_registrations_feedback_retrieve", + "operationId": "api_v1_admin_warranty_documents_url_retrieve", + "summary": "Create a signed warranty document view URL", "parameters": [ { "in": "path", @@ -58506,18 +59482,10 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Member events" + "Admin warranty" ], "security": [ { @@ -58529,13 +59497,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FeedbackForm" + "$ref": "#/components/schemas/WarrantyDocumentUrl" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -58543,7 +59511,7 @@ } } }, - "description": "Invalid feedback answers." + "description": "" }, "403": { "content": { @@ -58553,7 +59521,7 @@ } } }, - "description": "Active membership is required." + "description": "" }, "404": { "content": { @@ -58563,71 +59531,76 @@ } } }, - "description": "Feedback resource not found." + "description": "" }, - "409": { + "503": { + "description": "Warranty document storage is unavailable." + } + } + } + }, + "/api/v1/auth/account/deletion": { + "get": { + "operationId": "api_v1_auth_account_deletion_retrieve", + "description": "Preview global obligations and available fresh identity verification methods.", + "tags": [ + "Accounts" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/AccountDeletionPreview" } } }, - "description": "Feedback or certificate state conflict." + "description": "" + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Rate limit exceeded." + "description": "Rate limit exceeded; retry after the interval in Retry-After." + }, + "503": { + "description": "A required dependency is unavailable." } } }, "post": { - "operationId": "api_v1_member_makerspaces_event_registrations_feedback_create", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_auth_account_deletion_create", + "description": "Requires session-bound reauthentication within ten minutes. Atomically archives the account and revokes its credentials; retained records follow their retention policies.", "tags": [ - "Member events" + "Accounts" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FeedbackSubmission" + "$ref": "#/components/schemas/AccountDeletionRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/FeedbackSubmission" + "$ref": "#/components/schemas/AccountDeletionRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/FeedbackSubmission" + "$ref": "#/components/schemas/AccountDeletionRequest" } } - } + }, + "required": true }, "security": [ { @@ -58635,170 +59608,302 @@ } ], "responses": { - "201": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FeedbackSubmissionResponse" + "$ref": "#/components/schemas/AccountDeletionResult" } } }, "description": "" }, - "400": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/AccountDeletionRefused" } } }, - "description": "Invalid feedback answers." + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." + }, + "503": { + "description": "A required dependency is unavailable." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/AccountDeletionForbidden" } } }, - "description": "Active membership is required." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "description": "" + } + } + } + }, + "/api/v1/auth/account/restore/email": { + "post": { + "operationId": "api_v1_auth_account_restore_email_create", + "description": "Consume a single-use, attempt-capped, 24-hour restore link delivered by password recovery. Requires a new password; refuses at the purge deadline or once any purge started.", + "tags": [ + "Accounts" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountRestoreEmail" } }, - "description": "Feedback resource not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/AccountRestoreEmail" } }, - "description": "Feedback or certificate state conflict." + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/AccountRestoreEmail" + } + } }, - "429": { + "required": true + }, + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/AccountRestoreResult" } } }, - "description": "Rate limit exceeded." + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/event-registrations/{id}/qr": { - "get": { - "operationId": "api_v1_member_makerspaces_event_registrations_qr_retrieve", - "summary": "Render the caller's event check-in QR code", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "/api/v1/auth/account/restore/phone": { + "post": { + "operationId": "api_v1_auth_account_restore_phone_create", + "description": "Consume a restore OTP from phone/login/start for an archived phone-only account. Device delivery must prove the exact attestation challenge. Requires a new password.", "tags": [ - "Member activity" - ], - "security": [ - { - "jwtAuth": [] - } + "Accounts" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountRestorePhone" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/AccountRestorePhone" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/AccountRestorePhone" + } + } + }, + "required": true + }, "responses": { "200": { "content": { - "image/svg+xml": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/AccountRestoreResult" } } }, - "description": "Check-in QR code as SVG." + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "409": { + "description": "Conflicts with the current state." + }, + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." + }, + "503": { + "description": "A required dependency is unavailable." + } + } + } + }, + "/api/v1/auth/account/restore/social/complete": { + "post": { + "operationId": "api_v1_auth_account_restore_social_complete_create", + "description": "Fresh auth_time within five minutes is mandatory for Apple, Google and OIDC. OIDC authorization requests prompt=login and max_age=0. Providers without that proof cannot restore. Account eligibility and the original linked subject are rechecked under lock.", + "tags": [ + "Accounts" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountRestoreSocialComplete" } }, - "description": "Active membership or an owned account/invite registration is required." + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/AccountRestoreSocialComplete" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/AccountRestoreSocialComplete" + } + } }, - "400": { + "required": true + }, + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/AccountRestoreResult" } } }, "description": "" }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." + }, + "503": { + "description": "A required dependency is unavailable." + } + } + } + }, + "/api/v1/auth/account/restore/social/start": { + "post": { + "operationId": "api_v1_auth_account_restore_social_start_create", + "description": "Start a five-minute, single-use fresh sign-in for an already-linked provider. Browser delivery requires a registered member origin and same-origin redirect. Native Apple/Google delivery binds an approved app attestation challenge and audience. Only accounts without email or phone can restore through this flow. Never links identities.", + "tags": [ + "Accounts" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountRestoreSocialStart" } }, - "description": "" + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/AccountRestoreSocialStart" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/AccountRestoreSocialStart" + } + } }, - "404": { + "required": true + }, + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/AccountRestoreSocialResult" } } }, - "description": "Registration not found." + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." + }, + "503": { + "description": "A required dependency is unavailable." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/event-registrations/calendar.ics": { - "get": { - "operationId": "api_v1_member_makerspaces_event_registrations_calendar.ics_retrieve", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "/api/v1/auth/change-password": { + "post": { + "operationId": "api_v1_auth_change_password_create", + "summary": "Change current user's password", "tags": [ - "Member events" + "Auth" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangePassword" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ChangePassword" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ChangePassword" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -58807,26 +59912,62 @@ "responses": { "200": { "content": { - "text/calendar": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/ChangePasswordResponse" } } }, - "description": "RFC 5545 calendar (`text/calendar; charset=utf-8`)." + "description": "" + }, + "400": { + "description": "Password validation failed." }, "401": { + "description": "Authentication credentials were not provided." + } + } + } + }, + "/api/v1/auth/claim/redeem": { + "post": { + "operationId": "api_v1_auth_claim_redeem_create", + "summary": "Redeem a staff-issued member claim code", + "tags": [ + "Auth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClaimRedemption" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ClaimRedemption" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ClaimRedemption" + } + } + }, + "required": true + }, + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ClaimRedemptionResponse" } } }, - "description": "Authentication required." + "description": "" }, - "403": { + "400": { "content": { "application/json": { "schema": { @@ -58834,17 +59975,54 @@ } } }, - "description": "Active membership required." + "description": "Invalid or expired claim code." }, "404": { + "description": "Makerspace not found." + }, + "429": { + "description": "Redemption rate limit exceeded." + } + } + } + }, + "/api/v1/auth/device/attestation-challenge": { + "post": { + "operationId": "api_v1_auth_device_attestation_challenge_create", + "description": "Challenges require an approved deployment-global registration and snapshot its signing identities. Play Integrity accepts any registered distribution signer and grants retain the matched digest. External verifiers receive only the first signing identity. Submit the returned challenge with the device login attestation payload: Android {\"type\":\"play_integrity\",\"token\":\"...\"} uses Standard requestHash = base64url(SHA-256(challenge UTF-8)), without padding. iOS {\"type\":\"app_attest\",\"key_id\":\"\",\"attestation\":\"\"} uses a fresh key and clientDataHash = SHA-256(challenge UTF-8). Development-insecure mode accepts {\"type\":\"development\"} for development registrations only. External verifiers retain their deployment-specific payload format. Verifier outages return attestation_unavailable (503); restart with a new challenge.", + "tags": [ + "Device auth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceIdentity" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/DeviceIdentity" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/DeviceIdentity" + } + } + }, + "required": true + }, + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceChallengeResponse" } } }, - "description": "Makerspace not found." + "description": "" }, "400": { "content": { @@ -58854,67 +60032,106 @@ } } }, - "description": "Events module unavailable." + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/loans/{id}/renew": { - "post": { - "operationId": "api_v1_member_makerspaces_loans_renew_create", - "summary": "Extend an issued loan within its policy", - "parameters": [ + "/api/v1/auth/device/grants": { + "get": { + "operationId": "api_v1_auth_device_grants_list", + "tags": [ + "Device auth" + ], + "security": [ { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DeviceGrant" + } + } + } }, - "required": true + "description": "" }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/auth/device/grants/{grant_id}": { + "delete": { + "operationId": "api_v1_auth_device_grants_destroy", + "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "grant_id", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Loans" + "Device auth" ], - "requestBody": { - "content": { - "type": { - "schema": { - "type": "object", - "additionalProperties": {}, - "description": "Unspecified request body" - } - }, - "properties": { - "schema": {} - } - } - }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "204": { + "description": "No response body" + }, + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LoanRenewal" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "400": { + "404": { "content": { "application/json": { "schema": { @@ -58923,6 +60140,47 @@ } }, "description": "" + } + } + } + }, + "/api/v1/auth/device/login": { + "post": { + "operationId": "api_v1_auth_device_login_create", + "description": "Attested password sign-in. Local staff receive native_staff authority after any required MFA. New superadmin grants are refused with device_superadmin_not_supported (403). Challenges require an approved deployment-global registration and snapshot its signing identities. Play Integrity accepts any registered distribution signer and grants retain the matched digest. External verifiers receive only the first signing identity. Submit the returned challenge with the device login attestation payload: Android {\"type\":\"play_integrity\",\"token\":\"...\"} uses Standard requestHash = base64url(SHA-256(challenge UTF-8)), without padding. iOS {\"type\":\"app_attest\",\"key_id\":\"\",\"attestation\":\"\"} uses a fresh key and clientDataHash = SHA-256(challenge UTF-8). Development-insecure mode accepts {\"type\":\"development\"} for development registrations only. External verifiers retain their deployment-specific payload format. Verifier outages return attestation_unavailable (503); restart with a new challenge.", + "tags": [ + "Device auth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceLogin" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/DeviceLogin" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/DeviceLogin" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceLoginResult" + } + } + }, + "description": "" }, "401": { "content": { @@ -58944,7 +60202,7 @@ }, "description": "" }, - "404": { + "503": { "content": { "application/json": { "schema": { @@ -58957,22 +60215,11 @@ } } }, - "/api/v1/member/makerspaces/{makerspace_id}/member-card": { - "get": { - "operationId": "api_v1_member_makerspaces_member_card_retrieve", - "summary": "Read my member card", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "/api/v1/auth/device/logout": { + "post": { + "operationId": "api_v1_auth_device_logout_create", "tags": [ - "Member profile" + "Device auth" ], "security": [ { @@ -58984,124 +60231,126 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OwnMemberCard" + "$ref": "#/components/schemas/DeviceLogoutResponse" } } }, "description": "" }, - "400": { - "description": "Membership module disabled or invalid input." - }, "401": { - "description": "Authentication required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "An active membership is required." - }, - "404": { - "description": "No card has been issued to this membership." - } - } - }, - "patch": { - "operationId": "api_v1_member_makerspaces_member_card_partial_update", - "summary": "Set the name printed on my card", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true + "description": "" } - ], + } + } + }, + "/api/v1/auth/device/refresh": { + "post": { + "operationId": "api_v1_auth_device_refresh_create", "tags": [ - "Member profile" + "Device auth" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedMemberCardOwnUpdate" + "$ref": "#/components/schemas/DeviceRefresh" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedMemberCardOwnUpdate" + "$ref": "#/components/schemas/DeviceRefresh" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedMemberCardOwnUpdate" + "$ref": "#/components/schemas/DeviceRefresh" } } - } + }, + "required": true }, - "security": [ - { - "jwtAuth": [] - } - ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberCard" + "$ref": "#/components/schemas/DeviceRefreshResponse" } } }, "description": "" }, "400": { - "description": "Membership module disabled or invalid input." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "401": { - "description": "Authentication required." - }, - "403": { - "description": "An active membership is required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, - "404": { - "description": "No card has been issued to this membership." + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/member-card/photo": { + "/api/v1/auth/email-verification/confirm": { "post": { - "operationId": "api_v1_member_makerspaces_member_card_photo_create", - "summary": "Create a card photo upload URL (staging only)", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_auth_email_verification_confirm_create", "tags": [ - "Member profile" + "Auth" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberCardPhotoPresign" + "$ref": "#/components/schemas/EmailVerificationConfirm" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MemberCardPhotoPresign" + "$ref": "#/components/schemas/EmailVerificationConfirm" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MemberCardPhotoPresign" + "$ref": "#/components/schemas/EmailVerificationConfirm" } } }, @@ -59113,163 +60362,226 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/MemberVerificationAck" } } }, "description": "" }, "400": { - "description": "Membership module disabled or invalid input." + "description": "Invalid or expired verification code." }, "401": { - "description": "Authentication required." + "description": "Authentication credentials were not provided." }, "403": { - "description": "An active membership is required." - }, - "404": { - "description": "No card has been issued to this membership." + "description": "Permission denied." }, - "503": { - "description": "Storage unavailable." + "429": { + "description": "Request throttled." } } - }, - "put": { - "operationId": "api_v1_member_makerspaces_member_card_photo_update", - "summary": "Attach an uploaded card photo (requires consent)", - "parameters": [ + } + }, + "/api/v1/auth/email-verification/resend": { + "post": { + "operationId": "api_v1_auth_email_verification_resend_create", + "tags": [ + "Auth" + ], + "security": [ { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true + "jwtAuth": [] } ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberVerificationAck" + } + } + }, + "description": "" + }, + "400": { + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "429": { + "description": "Request throttled." + } + } + } + }, + "/api/v1/auth/forgot-password": { + "post": { + "operationId": "api_v1_auth_forgot_password_create", + "description": "Queues the same account-independent request for all addresses. The drain sends eligible archived accounts a single-use, 24-hour restore link instead of password-reset mail.", + "summary": "Request an emailed password reset code", "tags": [ - "Member profile" + "Auth" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberCardPhotoFinalize" + "$ref": "#/components/schemas/ForgotPasswordRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MemberCardPhotoFinalize" + "$ref": "#/components/schemas/ForgotPasswordRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MemberCardPhotoFinalize" + "$ref": "#/components/schemas/ForgotPasswordRequest" } } }, "required": true }, - "security": [ - { - "jwtAuth": [] - } - ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberCard" + "$ref": "#/components/schemas/PasswordResetAcknowledgement" } } }, "description": "" }, "400": { - "description": "Membership module disabled or invalid input." - }, - "401": { - "description": "Authentication required." + "description": "Invalid request." }, - "403": { - "description": "An active membership is required." + "429": { + "description": "Request throttled." }, - "404": { - "description": "No card has been issued to this membership." - } - } - }, - "delete": { - "operationId": "api_v1_member_makerspaces_member_card_photo_destroy", - "summary": "Remove my card photo", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecoveryUnavailable" + } + } }, - "required": true + "description": "" } - ], + } + } + }, + "/api/v1/auth/login": { + "post": { + "operationId": "api_v1_auth_login_create", + "description": "Takes a set of user credentials and returns an access and refresh JSON web\ntoken pair to prove the authentication of those credentials.", + "summary": "Log in with a password", "tags": [ - "Member profile" - ], - "security": [ - { - "jwtAuth": [] - } + "Auth" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + }, + "examples": { + "StaffLogin": { + "value": { + "username": "admin", + "password": "secret-password", + "surface": "staff" + }, + "summary": "Staff login" + } + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberCard" + "$ref": "#/components/schemas/PasswordLoginResult" } } }, "description": "" }, "400": { - "description": "Membership module disabled or invalid input." + "description": "Invalid request." }, "401": { - "description": "Authentication required." + "description": "Invalid credentials or inactive account." }, "403": { - "description": "An active membership is required." + "description": "Account access is restricted." }, - "404": { - "description": "No card has been issued to this membership." + "429": { + "description": "Request throttled." + }, + "503": { + "description": "Authentication write boundary unavailable." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/member-card/preview.pdf": { - "get": { - "operationId": "api_v1_member_makerspaces_member_card_preview.pdf_retrieve", - "summary": "Preview my card as a watermarked PDF", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" + "/api/v1/auth/logout": { + "post": { + "operationId": "api_v1_auth_logout_create", + "summary": "Log out and clear refresh cookie", + "tags": [ + "Auth" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogoutResponse" + } + } }, - "required": true + "description": "" + }, + "401": { + "description": "Refresh token could not be blacklisted." + }, + "403": { + "description": "CSRF check failed." } - ], + } + } + }, + "/api/v1/auth/logout-all": { + "post": { + "operationId": "api_v1_auth_logout_all_create", + "summary": "Sign out of every browser and device session", "tags": [ - "Member profile" + "Auth" ], "security": [ { @@ -59279,101 +60591,25 @@ "responses": { "200": { "content": { - "application/pdf": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/LogoutAllResponse" } } }, "description": "" }, - "400": { - "description": "Membership module disabled or invalid input." - }, "401": { - "description": "Authentication required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "An active membership is required." - }, - "404": { - "description": "No card has been issued to this membership." - } - } - } - }, - "/api/v1/member/makerspaces/{makerspace_id}/payment-documents": { - "get": { - "operationId": "api_v1_member_makerspaces_payment_documents_retrieve", - "summary": "List scoped payment documents", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer" - } - } - ], - "tags": [ - "Payments" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DocumentPage" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "404": { "content": { "application/json": { "schema": { @@ -59386,30 +60622,12 @@ } } }, - "/api/v1/member/makerspaces/{makerspace_id}/payment-documents/{document_id}/download-url": { + "/api/v1/auth/me": { "get": { - "operationId": "api_v1_member_makerspaces_payment_documents_download_url_retrieve", - "summary": "Get a user-bound five-minute PDF download URL", - "parameters": [ - { - "in": "path", - "name": "document_id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_auth_me_retrieve", + "summary": "Get current session profile", "tags": [ - "Payments" + "Auth" ], "security": [ { @@ -59421,161 +60639,84 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DownloadGrant" + "$ref": "#/components/schemas/AuthUserPayload" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Invalid request." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Authentication credentials were not provided." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Permission denied." }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "429": { + "description": "Request throttled." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/payments": { - "get": { - "operationId": "api_v1_member_makerspaces_payments_list", - "description": "Active memberships and account-only charge owners may read their own history. A revoked member reapplying through an approved admission may read only that application's pending admission charge; previous membership debt stays refused.", - "summary": "List the caller's payment history", - "parameters": [ - { - "in": "query", - "name": "due_state", - "schema": { - "type": "string", - "enum": [ - "due_today", - "overdue", - "upcoming" - ] - } - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "/api/v1/auth/member-sign-up": { + "post": { + "operationId": "api_v1_auth_member_sign_up_create", "tags": [ - "Payments" - ], - "security": [ - { - "jwtAuth": [] - } + "Auth" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberSignUp" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MemberSignUp" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MemberSignUp" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberPayment" - } + "$ref": "#/components/schemas/MemberVerificationAck" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaymentFilterError" - } - } - }, - "description": "" + "description": "Invalid details." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Authentication required." + "description": "Authentication failed." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "No active membership and no charge here." + "description": "Permission denied." + }, + "429": { + "description": "Request throttled." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/payments/{payment_id}/checkout": { - "post": { - "operationId": "api_v1_member_makerspaces_payments_checkout_create", - "summary": "Generate a Checkout link for the caller's pending payment", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "path", - "name": "payment_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "/api/v1/auth/mfa": { + "get": { + "operationId": "api_v1_auth_mfa_retrieve", "tags": [ - "Payments" + "MFA" ], "security": [ { @@ -59587,80 +60728,47 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CheckoutUrl" + "$ref": "#/components/schemas/MFAStatus" } } }, "description": "" }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Authentication required." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "No pending charge of yours here." + "description": "Not signed in." }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "`payment_rail_conflict`: the charge uses the native rail." + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "`payments_unavailable`: no live online rail for this charge." + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." } } - } - }, - "/api/v1/member/makerspaces/{makerspace_id}/payments/{payment_id}/mobile-intent": { + }, "post": { - "operationId": "api_v1_member_makerspaces_payments_mobile_intent_create", - "summary": "Create or retrieve a native mobile payment intent", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" + "operationId": "api_v1_auth_mfa_create", + "tags": [ + "MFA" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MFAEnrollmentStart" + } }, - "required": true - }, - { - "in": "path", - "name": "payment_id", - "schema": { - "type": "integer" + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MFAEnrollmentStart" + } }, - "required": true + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MFAEnrollmentStart" + } + } } - ], - "tags": [ - "Payments" - ], + }, "security": [ { "jwtAuth": [] @@ -59671,157 +60779,111 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MobilePaymentIntentResponse" + "$ref": "#/components/schemas/MFAChallengeResponse" } } }, "description": "" }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." }, "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "A required dependency is unavailable." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/profile": { - "get": { - "operationId": "api_v1_member_makerspaces_profile_retrieve", - "summary": "Retrieve the caller's own profile", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "/api/v1/auth/mfa/confirm": { + "post": { + "operationId": "api_v1_auth_mfa_confirm_create", + "description": "Complete the server-owned login context. New device grants retain login provenance and derive native_staff authority only for password/social local staff. New superadmin grants return device_superadmin_not_supported (403). Attestation outages return attestation_unavailable (503).", "tags": [ - "Member profile" - ], - "security": [ - { - "jwtAuth": [] - } + "MFA" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MFAVerify" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MFAVerify" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MFAVerify" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileRead" + "$ref": "#/components/schemas/MFACompleted" } } }, "description": "" }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, "401": { - "description": "Authentication required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "An active membership is required." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Not found." + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." + }, + "503": { + "description": "A required dependency is unavailable." } } - }, - "put": { - "operationId": "api_v1_member_makerspaces_profile_update", - "summary": "Update the caller's own profile", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + } + }, + "/api/v1/auth/mfa/disable": { + "post": { + "operationId": "api_v1_auth_mfa_disable_create", "tags": [ - "Member profile" + "MFA" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileWrite" + "$ref": "#/components/schemas/MFAPassword" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ProfileWrite" + "$ref": "#/components/schemas/MFAPassword" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ProfileWrite" + "$ref": "#/components/schemas/MFAPassword" } } - } + }, + "required": true }, "security": [ { @@ -59833,136 +60895,106 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileRead" + "$ref": "#/components/schemas/MFAStatus" } } }, "description": "" }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, "401": { - "description": "Authentication required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "An active membership is required." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Not found." + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." + }, + "503": { + "description": "A required dependency is unavailable." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/profile/image": { + "/api/v1/auth/mfa/enroll": { "post": { - "operationId": "api_v1_member_makerspaces_profile_image_create", - "summary": "Create a profile image upload URL", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_auth_mfa_enroll_create", "tags": [ - "Member profile" + "MFA" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileImageUploadRequest" + "$ref": "#/components/schemas/MFAChallenge" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ProfileImageUploadRequest" + "$ref": "#/components/schemas/MFAChallenge" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ProfileImageUploadRequest" + "$ref": "#/components/schemas/MFAChallenge" } } }, "required": true }, - "security": [ - { - "jwtAuth": [] - } - ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicImageUploadResponse" + "$ref": "#/components/schemas/MFAEnrollment" } } }, "description": "" }, "400": { - "description": "Invalid image upload request." + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." }, "403": { - "description": "An active membership is required." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "429": { - "description": "Too many image upload requests." + "description": "Rate limit exceeded; retry after the interval in Retry-After." }, "503": { - "description": "Public image storage is unavailable." + "description": "A required dependency is unavailable." } } - }, - "put": { - "operationId": "api_v1_member_makerspaces_profile_image_update", - "summary": "Attach an uploaded image to the profile or one of its projects", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + } + }, + "/api/v1/auth/mfa/recovery": { + "post": { + "operationId": "api_v1_auth_mfa_recovery_create", "tags": [ - "Member profile" + "MFA" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileImageAttachRequest" + "$ref": "#/components/schemas/MFAPassword" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ProfileImageAttachRequest" + "$ref": "#/components/schemas/MFAPassword" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ProfileImageAttachRequest" + "$ref": "#/components/schemas/MFAPassword" } } }, @@ -59978,200 +61010,108 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileRead" + "$ref": "#/components/schemas/MFARecovery" } } }, "description": "" }, "400": { - "description": "Invalid image object key or size." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, - "403": { - "description": "An active membership is required." + "401": { + "description": "Not signed in." }, - "503": { - "description": "Public image storage is unavailable." - } - } - }, - "delete": { - "operationId": "api_v1_member_makerspaces_profile_image_destroy", - "summary": "Clear a profile or project image", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Member profile" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProfileRead" - } - } - }, - "description": "" + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "400": { - "description": "Unknown project." + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." }, - "403": { - "description": "An active membership is required." + "503": { + "description": "A required dependency is unavailable." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/referrals": { + "/api/v1/auth/mfa/verify": { "post": { - "operationId": "api_v1_member_makerspaces_referrals_create", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_auth_mfa_verify_create", + "description": "Complete the server-owned login context. New device grants retain login provenance and derive native_staff authority only for password/social local staff. New superadmin grants return device_superadmin_not_supported (403). Attestation outages return attestation_unavailable (503).", "tags": [ - "Memberships" + "MFA" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReferralCreate" + "$ref": "#/components/schemas/MFAVerify" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ReferralCreate" + "$ref": "#/components/schemas/MFAVerify" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ReferralCreate" + "$ref": "#/components/schemas/MFAVerify" } } }, "required": true }, - "security": [ - { - "jwtAuth": [] - } - ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReferralOutcome" + "$ref": "#/components/schemas/MFACompleted" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "503": { + "description": "A required dependency is unavailable." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/verification": { + "/api/v1/auth/organization-invitations/redeem/": { "post": { - "operationId": "api_v1_member_makerspaces_verification_create", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_auth_organization_invitations_redeem_create", + "summary": "Redeem a single-use organization invitation", "tags": [ - "Memberships" + "Auth" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberVerificationRequest" + "$ref": "#/components/schemas/OrganizationInvitationRedeem" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MemberVerificationRequest" + "$ref": "#/components/schemas/OrganizationInvitationRedeem" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MemberVerificationRequest" + "$ref": "#/components/schemas/OrganizationInvitationRedeem" } } }, @@ -60187,21 +61127,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberVerificationResponse" + "$ref": "#/components/schemas/OrganizationInvitationRedeemed" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Malformed token." }, "401": { "content": { @@ -60224,6 +61157,9 @@ "description": "" }, "404": { + "description": "Invitation not found." + }, + "409": { "content": { "application/json": { "schema": { @@ -60236,21 +61172,11 @@ } } }, - "/api/v1/member/makerspaces/{makerspace_id}/waiver": { + "/api/v1/auth/organizations/self-service/": { "get": { - "operationId": "api_v1_member_makerspaces_waiver_retrieve", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_auth_organizations_self_service_retrieve", "tags": [ - "Memberships" + "Auth" ], "security": [ { @@ -60262,17 +61188,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberWaiverResponse" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/OrganizationCreationPolicy" } } }, @@ -60282,102 +61198,106 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "" + "description": "Not signed in." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "" + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "" + "description": "Rate limit exceeded; retry after the interval in Retry-After." } } - } - }, - "/api/v1/member/makerspaces/{makerspace_id}/waiver/accept": { + }, "post": { - "operationId": "api_v1_member_makerspaces_waiver_accept_create", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_auth_organizations_self_service_create", "tags": [ - "Memberships" + "Auth" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationSelfCreate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/OrganizationSelfCreate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/OrganizationSelfCreate" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WaiverAcceptResponse" + "$ref": "#/components/schemas/OrganizationDetail" } } }, @@ -60387,119 +61307,175 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "" + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "" + "description": "Not signed in." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "" + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "" + "description": "Conflicts with the current state." }, "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": true, + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } } } }, - "description": "" - }, - "503": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." + } + } + } + }, + "/api/v1/auth/phone": { + "delete": { + "operationId": "api_v1_auth_phone_destroy", + "description": "Detach the number. Always safe: every account keeps an email credential.", + "summary": "Unlink the phone number", + "tags": [ + "Auth" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PhoneStatus" } } }, "description": "" + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." } } } }, - "/api/v1/memberships/{id}/accept-invitation": { + "/api/v1/auth/phone/link/confirm": { "post": { - "operationId": "api_v1_memberships_accept_invitation_create", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_auth_phone_link_confirm_create", + "description": "Attach the verified number to the caller's account.", + "summary": "Confirm and link a phone number", "tags": [ - "Memberships" + "Auth" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdmissionAnswers" + "$ref": "#/components/schemas/PhoneConfirm" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/AdmissionAnswers" + "$ref": "#/components/schemas/PhoneConfirm" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/AdmissionAnswers" + "$ref": "#/components/schemas/PhoneConfirm" } } - } + }, + "required": true }, "security": [ { @@ -60511,81 +61487,49 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvitationClaimOutcome" + "$ref": "#/components/schemas/PhoneStatus" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Invalid or expired code." }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "429": { + "description": "Request throttled." } } } }, - "/api/v1/memberships/invitations": { - "get": { - "operationId": "api_v1_memberships_invitations_retrieve", + "/api/v1/auth/phone/link/start": { + "post": { + "operationId": "api_v1_auth_phone_link_start_create", + "description": "Send a code to a number the caller wants to attach to their own account.", + "summary": "Request a code to link a phone number", "tags": [ - "Memberships" + "Auth" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PhoneStart" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PhoneStart" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PhoneStart" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -60596,145 +61540,68 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvitationList" + "$ref": "#/components/schemas/PhoneStartResponse" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Invalid or already-linked number." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Phone sign-in is not configured." }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "429": { + "description": "Request throttled." } } } }, - "/api/v1/memberships/invitations/{id}/claim": { + "/api/v1/auth/phone/login/confirm": { "post": { - "operationId": "api_v1_memberships_invitations_claim_create", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_auth_phone_login_confirm_create", + "description": "Device delivery creates a new member-only grant, or returns an MFA challenge when required, without setting cookies. Superadmins receive device_superadmin_not_supported (403). Rejected or consumed device challenges return phone_device_restart_required (409). Challenges require an approved deployment-global registration and snapshot its signing identities. Play Integrity accepts any registered distribution signer and grants retain the matched digest. External verifiers receive only the first signing identity. Submit the returned challenge with the device login attestation payload: Android {\"type\":\"play_integrity\",\"token\":\"...\"} uses Standard requestHash = base64url(SHA-256(challenge UTF-8)), without padding. iOS {\"type\":\"app_attest\",\"key_id\":\"\",\"attestation\":\"\"} uses a fresh key and clientDataHash = SHA-256(challenge UTF-8). Development-insecure mode accepts {\"type\":\"development\"} for development registrations only. External verifiers retain their deployment-specific payload format. Verifier outages return attestation_unavailable (503); restart with a new challenge.", + "summary": "Sign in with a phone code", "tags": [ - "Memberships" + "Auth" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdmissionAnswers" + "$ref": "#/components/schemas/PhoneLoginConfirm" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/AdmissionAnswers" + "$ref": "#/components/schemas/PhoneLoginConfirm" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/AdmissionAnswers" + "$ref": "#/components/schemas/PhoneLoginConfirm" } } - } + }, + "required": true }, - "security": [ - { - "jwtAuth": [] - } - ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvitationClaimOutcome" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PhoneLoginResult" } } }, "description": "" }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "The first-factor account is no longer available." + }, + "400": { + "description": "Invalid or expired code." }, "403": { "content": { @@ -60744,19 +61611,12 @@ } } }, - "description": "" + "description": "device_superadmin_not_supported: superadmins cannot create grants." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Phone sign-in is not configured." }, - "423": { + "409": { "content": { "application/json": { "schema": { @@ -60764,7 +61624,10 @@ } } }, - "description": "" + "description": "phone_device_restart_required: restart with new one-time inputs." + }, + "429": { + "description": "Request throttled." }, "503": { "content": { @@ -60774,169 +61637,67 @@ } } }, - "description": "" + "description": "attestation_unavailable or authentication write boundary unavailable." } } } }, - "/api/v1/memberships/me": { - "get": { - "operationId": "api_v1_memberships_me_retrieve", + "/api/v1/auth/phone/login/start": { + "post": { + "operationId": "api_v1_auth_phone_login_start_create", + "description": "Archived phone-only accounts receive a restore OTP instead of a login OTP, redeemable at account/restore/phone. Device delivery binds the OTP to an approved attestation challenge and matching ios/android client_platform. Unknown numbers, invalid challenges and cooldowns receive the same acknowledgement.", + "summary": "Request a phone sign-in code", "tags": [ - "Memberships" - ], - "security": [ - { - "jwtAuth": [] - } + "Auth" ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MyMemberships" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PhoneLoginStart" } }, - "description": "" - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PhoneLoginStart" } }, - "description": "" - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PhoneLoginStart" } - }, - "description": "" + } }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - } - } - } - }, - "/api/v1/metrics/": { - "get": { - "operationId": "api_v1_metrics_retrieve", - "description": "Prometheus text exposition of queue depth, request states, notification delivery outcomes, storage accounting and evidence retention. Requires the deployment's METRICS_TOKEN as a bearer token; 404 when no token is configured.", - "summary": "Prometheus metrics", - "tags": [ - "Health" - ], - "security": [ - {} - ], + "required": true + }, "responses": { "200": { "content": { - "text/plain": { + "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/PhoneStartResponse" } } }, "description": "" }, - "401": { - "description": "Missing or wrong METRICS_TOKEN." + "400": { + "description": "Invalid request." }, "404": { - "description": "METRICS_TOKEN is not set." + "description": "Phone sign-in is not configured." + }, + "429": { + "description": "Request throttled." } } } }, - "/api/v1/notifications/announcements/{makerspace_id}": { + "/api/v1/auth/reauth": { "get": { - "operationId": "api_v1_notifications_announcements_retrieve", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer" - } - } - ], + "operationId": "api_v1_auth_reauth_retrieve", "tags": [ - "Member communication" + "Reauthentication" ], "security": [ { @@ -60948,7 +61709,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InboxPage" + "$ref": "#/components/schemas/ReauthenticationStatus" } } }, @@ -60963,28 +61724,38 @@ "403": { "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." + }, + "503": { + "description": "A required dependency is unavailable." } } - } - }, - "/api/v1/notifications/broadcasts/{makerspace_id}": { - "get": { - "operationId": "api_v1_notifications_broadcasts_retrieve", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + }, + "post": { + "operationId": "api_v1_auth_reauth_create", "tags": [ - "Member communication" + "Reauthentication" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Reauthentication" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Reauthentication" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Reauthentication" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -60995,7 +61766,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BroadcastOptions" + "$ref": "#/components/schemas/ReauthenticationResult" } } }, @@ -61010,41 +61781,36 @@ "403": { "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." + }, + "503": { + "description": "A required dependency is unavailable." } } - }, + } + }, + "/api/v1/auth/reauth/enrollment/confirm": { "post": { - "operationId": "api_v1_notifications_broadcasts_create", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_auth_reauth_enrollment_confirm_create", "tags": [ - "Member communication" + "Reauthentication" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BroadcastInput" + "$ref": "#/components/schemas/ReauthenticationEmailConfirm" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/BroadcastInput" + "$ref": "#/components/schemas/ReauthenticationEmailConfirm" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/BroadcastInput" + "$ref": "#/components/schemas/ReauthenticationEmailConfirm" } } }, @@ -61056,11 +61822,11 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BroadcastResult" + "$ref": "#/components/schemas/ReauthenticationEmailConfirmed" } } }, @@ -61075,11 +61841,8 @@ "403": { "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "423": { - "description": "Makerspace writes are frozen for tenant migration." + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." }, "503": { "description": "A required dependency is unavailable." @@ -61087,55 +61850,11 @@ } } }, - "/api/v1/notifications/inbox/{makerspace_id}": { - "get": { - "operationId": "api_v1_notifications_inbox_retrieve", - "parameters": [ - { - "in": "query", - "name": "feature", - "schema": { - "type": "string", - "enum": [ - "api_access", - "bookings", - "certifications", - "devices", - "events", - "hardware_requests", - "maintenance", - "members", - "payments", - "printing", - "stock" - ] - } - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer" - } - }, - { - "in": "query", - "name": "unread", - "schema": { - "type": "boolean" - } - } - ], + "/api/v1/auth/reauth/enrollment/email": { + "post": { + "operationId": "api_v1_auth_reauth_enrollment_email_create", "tags": [ - "Personal notifications" + "Reauthentication" ], "security": [ { @@ -61147,7 +61866,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InboxPage" + "$ref": "#/components/schemas/ReauthenticationEmailSent" } } }, @@ -61162,36 +61881,41 @@ "403": { "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." + }, + "503": { + "description": "A required dependency is unavailable." } } } }, - "/api/v1/notifications/inbox/{makerspace_id}/{id}/read": { + "/api/v1/auth/reauth/phone/confirm": { "post": { - "operationId": "api_v1_notifications_inbox_read_create", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_auth_reauth_phone_confirm_create", "tags": [ - "Personal notifications" + "Reauthentication" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReauthenticationFactor" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ReauthenticationFactor" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ReauthenticationFactor" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -61202,7 +61926,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Inbox" + "$ref": "#/components/schemas/ReauthenticationResult" } } }, @@ -61217,11 +61941,8 @@ "403": { "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "423": { - "description": "Makerspace writes are frozen for tenant migration." + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." }, "503": { "description": "A required dependency is unavailable." @@ -61229,21 +61950,11 @@ } } }, - "/api/v1/notifications/inbox/{makerspace_id}/read-all": { + "/api/v1/auth/reauth/phone/start": { "post": { - "operationId": "api_v1_notifications_inbox_read_all_create", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_auth_reauth_phone_start_create", "tags": [ - "Personal notifications" + "Reauthentication" ], "security": [ { @@ -61255,7 +61966,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReadAll" + "$ref": "#/components/schemas/ReauthenticationPhoneStarted" } } }, @@ -61270,11 +61981,8 @@ "403": { "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." - }, - "423": { - "description": "Makerspace writes are frozen for tenant migration." + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." }, "503": { "description": "A required dependency is unavailable." @@ -61282,22 +61990,32 @@ } } }, - "/api/v1/notifications/inbox/{makerspace_id}/unread-count": { - "get": { - "operationId": "api_v1_notifications_inbox_unread_count_retrieve", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "/api/v1/auth/reauth/social/complete": { + "post": { + "operationId": "api_v1_auth_reauth_social_complete_create", "tags": [ - "Personal notifications" + "Reauthentication" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReauthenticationSocialComplete" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ReauthenticationSocialComplete" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ReauthenticationSocialComplete" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -61308,7 +62026,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Count" + "$ref": "#/components/schemas/ReauthenticationResult" } } }, @@ -61323,44 +62041,41 @@ "403": { "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." + }, + "503": { + "description": "A required dependency is unavailable." } } } }, - "/api/v1/notifications/makerspace/{makerspace_id}": { - "get": { - "operationId": "api_v1_notifications_makerspace_list", - "summary": "List makerspace notifications", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" + "/api/v1/auth/reauth/social/factor": { + "post": { + "operationId": "api_v1_auth_reauth_social_factor_create", + "tags": [ + "Reauthentication" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReauthenticationFactor" + } }, - "required": true - }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer" + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ReauthenticationFactor" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ReauthenticationFactor" + } } }, - { - "in": "query", - "name": "unread", - "schema": { - "type": "boolean" - }, - "description": "When true, return only unread notifications." - } - ], - "tags": [ - "Notifications" - ], + "required": true + }, "security": [ { "jwtAuth": [] @@ -61371,49 +62086,56 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedNotificationList" + "$ref": "#/components/schemas/ReauthenticationResult" } } }, "description": "" }, "400": { - "description": "Notifications module is disabled." + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." }, "403": { - "description": "Permission denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "404": { - "description": "Not found." + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." + }, + "503": { + "description": "A required dependency is unavailable." } } } }, - "/api/v1/notifications/makerspace/{makerspace_id}/{id}/read": { + "/api/v1/auth/reauth/social/start": { "post": { - "operationId": "api_v1_notifications_makerspace_read_create", - "summary": "Mark a makerspace notification read", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_auth_reauth_social_start_create", "tags": [ - "Notifications" + "Reauthentication" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReauthenticationSocialStart" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ReauthenticationSocialStart" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ReauthenticationSocialStart" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -61424,205 +62146,144 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Notification" + "$ref": "#/components/schemas/ReauthenticationSocialStarted" } } }, "description": "" }, "400": { - "description": "Notifications module is disabled." + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." }, "403": { - "description": "Permission denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "404": { - "description": "Not found." + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." + }, + "503": { + "description": "A required dependency is unavailable." } } } }, - "/api/v1/notifications/makerspace/{makerspace_id}/read-all": { + "/api/v1/auth/refresh": { "post": { - "operationId": "api_v1_notifications_makerspace_read_all_create", - "summary": "Mark all makerspace notifications read", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_auth_refresh_create", + "description": "Takes a refresh type JSON web token and returns an access type JSON web\ntoken if the refresh token is valid.", + "summary": "Refresh access token", "tags": [ - "Notifications" - ], - "security": [ - { - "jwtAuth": [] - } + "Auth" ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationMarkAllRead" + "$ref": "#/components/schemas/RefreshResponse" } } }, "description": "" }, - "400": { - "description": "Notifications module is disabled." + "401": { + "description": "Missing, invalid, or replayed refresh token." }, "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." + "description": "CSRF check failed or account restricted." } } } }, - "/api/v1/notifications/makerspace/{makerspace_id}/unread-count": { - "get": { - "operationId": "api_v1_notifications_makerspace_unread_count_retrieve", - "summary": "Get unread makerspace notification count", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "/api/v1/auth/reset-password": { + "post": { + "operationId": "api_v1_auth_reset_password_create", + "summary": "Confirm an OTP or coexisting legacy password reset link", "tags": [ - "Notifications" + "Auth" ], - "security": [ - { - "jwtAuth": [] + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordConfirmRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordConfirmRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordConfirmRequest" + } + } } - ], + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationUnreadCount" + "$ref": "#/components/schemas/PasswordUpdated" } } }, "description": "" }, "400": { - "description": "Notifications module is disabled." - }, - "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." - } - } - } - }, - "/api/v1/notifications/preferences/{makerspace_id}": { - "get": { - "operationId": "api_v1_notifications_preferences_retrieve", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Personal notifications" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PreferenceResponse" + "$ref": "#/components/schemas/ResetPasswordConfirmError" } } }, "description": "" }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "401": { - "description": "Not signed in." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "429": { + "description": "Request throttled." } } - }, - "patch": { - "operationId": "api_v1_notifications_preferences_partial_update", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + } + }, + "/api/v1/auth/social/apple": { + "post": { + "operationId": "api_v1_auth_social_apple_create", + "description": "Social sign-in with web or attested device delivery. Native Apple audiences bind to the approved iOS app ID on the attestation challenge (or existing grant). New device grants derive staff authority from active local memberships. Superadmin device creation returns device_superadmin_not_supported (403); existing grants remain usable. Attestation outages return attestation_unavailable (503); obtain a new nonce and challenge before retrying.", "tags": [ - "Personal notifications" + "Social auth" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedPreferenceWrite" + "$ref": "#/components/schemas/SocialLogin" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedPreferenceWrite" + "$ref": "#/components/schemas/SocialLogin" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedPreferenceWrite" + "$ref": "#/components/schemas/SocialLogin" } } - } + }, + "required": true }, - "security": [ - { - "jwtAuth": [] - } - ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PreferenceResponse" + "$ref": "#/components/schemas/SocialLoginResponse" } } }, @@ -61640,8 +62301,18 @@ "404": { "description": "Not found, not visible to this actor, or the surface is not enabled here." }, - "423": { - "description": "Makerspace writes are frozen for tenant migration." + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Includes `social_device_restart_required` when burned pre-grant inputs must be replaced." + }, + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." }, "503": { "description": "A required dependency is unavailable." @@ -61649,56 +62320,38 @@ } } }, - "/api/v1/notifications/unsubscribe/{token}": { + "/api/v1/auth/social/github/authorize": { "post": { - "operationId": "api_v1_notifications_unsubscribe_create", - "parameters": [ - { - "in": "path", - "name": "token", - "schema": { - "type": "string" - }, - "required": true - } - ], + "operationId": "api_v1_auth_social_github_authorize_create", "tags": [ - "Personal notifications" + "Social auth" ], "requestBody": { "content": { - "type": { + "application/json": { "schema": { - "type": "object", - "additionalProperties": {}, - "description": "Unspecified request body" + "$ref": "#/components/schemas/GithubBrowserStart" } }, - "required": { + "application/x-www-form-urlencoded": { "schema": { - "type": "object", - "additionalProperties": {}, - "description": "Unspecified request body" + "$ref": "#/components/schemas/GithubBrowserStart" } }, - "properties": { + "multipart/form-data": { "schema": { - "List-Unsubscribe": { - "type": "string", - "enum": [ - "One-Click" - ] - } + "$ref": "#/components/schemas/GithubBrowserStart" } } - } + }, + "required": true }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnsubscribeResult" + "$ref": "#/components/schemas/GithubBrowserStartResponse" } } }, @@ -61707,14 +62360,11 @@ "400": { "description": "Validation failed, or the makerspace module this route belongs to is off." }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "415": { - "description": "Unsupported media type for the request body." - }, - "423": { - "description": "Makerspace writes are frozen for tenant migration." + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." }, "503": { "description": "A required dependency is unavailable." @@ -61722,79 +62372,115 @@ } } }, - "/api/v1/payment-documents/{document_id}/download": { - "get": { - "operationId": "api_v1_payment_documents_download_retrieve", - "summary": "Download an issued payment document PDF", - "parameters": [ - { - "in": "path", - "name": "document_id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "query", - "name": "token", - "schema": { - "type": "string" - }, - "required": true - } - ], + "/api/v1/auth/social/github/callback": { + "post": { + "operationId": "api_v1_auth_social_github_callback_create", "tags": [ - "Payments" - ], - "security": [ - { - "jwtAuth": [] - } + "Social auth" ], - "responses": { - "200": { - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OidcBrowserCallback" } }, - "description": "" + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/OidcBrowserCallback" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/OidcBrowserCallback" + } + } }, - "400": { + "required": true + }, + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/OidcBrowserLoginResponse" } } }, "description": "" }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "409": { + "description": "Conflicts with the current state." + }, + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." + }, + "503": { + "description": "A required dependency is unavailable." + } + } + } + }, + "/api/v1/auth/social/google": { + "post": { + "operationId": "api_v1_auth_social_google_create", + "description": "Social sign-in with web or attested device delivery. Native Apple audiences bind to the approved iOS app ID on the attestation challenge (or existing grant). New device grants derive staff authority from active local memberships. Superadmin device creation returns device_superadmin_not_supported (403); existing grants remain usable. Attestation outages return attestation_unavailable (503); obtain a new nonce and challenge before retrying.", + "tags": [ + "Social auth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocialLogin" } }, - "description": "" + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/SocialLogin" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/SocialLogin" + } + } }, - "403": { + "required": true + }, + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/SocialLoginResponse" } } }, "description": "" }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "409": { "content": { "application/json": { "schema": { @@ -61802,274 +62488,190 @@ } } }, - "description": "" - } - } - } - }, - "/api/v1/payments/connect/callback": { - "get": { - "operationId": "api_v1_payments_connect_callback_retrieve", - "summary": "Complete Stripe Connect onboarding", - "parameters": [ - { - "in": "query", - "name": "code", - "schema": { - "type": "string" - } + "description": "Includes `social_device_restart_required` when burned pre-grant inputs must be replaced." }, - { - "in": "query", - "name": "error", - "schema": { - "type": "string" - } + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." }, - { - "in": "query", - "name": "state", - "schema": { - "type": "string" - }, - "required": true - } - ], - "tags": [ - "Payments" - ], - "responses": { - "302": { - "description": "Redirect to trusted staff settings." + "503": { + "description": "A required dependency is unavailable." } } } }, - "/api/v1/procurement/makerspace/{makerspace_id}/to-buy": { - "get": { - "operationId": "api_v1_procurement_makerspace_to_buy_list", - "summary": "List to-buy items for a makerspace", - "parameters": [ - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "approved", - "cancelled", - "ordered", - "received", - "requested" - ] - }, - "description": "Filter by procurement item status." - } - ], + "/api/v1/auth/social/nonce": { + "post": { + "operationId": "api_v1_auth_social_nonce_create", "tags": [ - "Procurement" - ], - "security": [ - { - "jwtAuth": [] - } + "Social auth" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocialNonce" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/SocialNonce" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/SocialNonce" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ToBuyItem" - } + "$ref": "#/components/schemas/SocialNonceResponse" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Invalid request." - }, - "401": { - "description": "Authentication credentials were not provided." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "403": { - "description": "Permission denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "description": "Not found." + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." } } - }, + } + }, + "/api/v1/auth/social/oidc/{slug}": { "post": { - "operationId": "api_v1_procurement_makerspace_to_buy_create", - "summary": "Add a to-buy item", + "operationId": "api_v1_auth_social_oidc_create", + "description": "Social sign-in with web or attested device delivery. Native Apple audiences bind to the approved iOS app ID on the attestation challenge (or existing grant). New device grants derive staff authority from active local memberships. Superadmin device creation returns device_superadmin_not_supported (403); existing grants remain usable. Attestation outages return attestation_unavailable (503); obtain a new nonce and challenge before retrying.", "parameters": [ - { - "in": "query", - "name": "kind", - "schema": { - "type": "string", - "enum": [ - "hardware", - "printing" - ] - }, - "description": "Stream to add to. Honored only for makerspace admins/superadmin; other roles are auto-tagged by role." - }, { "in": "path", - "name": "makerspace_id", + "name": "slug", "schema": { - "type": "integer" + "type": "string" }, "required": true } ], "tags": [ - "Procurement" + "Social auth" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToBuyItem" + "$ref": "#/components/schemas/SocialLogin" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ToBuyItem" + "$ref": "#/components/schemas/SocialLogin" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ToBuyItem" + "$ref": "#/components/schemas/SocialLogin" } } }, "required": true }, - "security": [ - { - "jwtAuth": [] - } - ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToBuyItem" + "$ref": "#/components/schemas/SocialLoginResponse" } } }, "description": "" }, "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request." - }, - "401": { - "description": "Authentication credentials were not provided." + "description": "Includes `social_device_restart_required` when burned pre-grant inputs must be replaced." }, - "403": { - "description": "Permission denied." + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." }, - "404": { - "description": "Not found." + "503": { + "description": "A required dependency is unavailable." } } } }, - "/api/v1/procurement/makerspace/{makerspace_id}/to-buy/export": { - "get": { - "operationId": "api_v1_procurement_makerspace_to_buy_export_retrieve", - "summary": "Export to-buy items as CSV or XLSX", + "/api/v1/auth/social/oidc/{slug}/authorize": { + "post": { + "operationId": "api_v1_auth_social_oidc_authorize_create", "parameters": [ - { - "in": "query", - "name": "format", - "schema": { - "type": "string", - "enum": [ - "csv", - "xlsx" - ] - } - }, { "in": "path", - "name": "makerspace_id", + "name": "slug", "schema": { - "type": "integer" + "type": "string" }, "required": true - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "approved", - "cancelled", - "ordered", - "received", - "requested" - ] - }, - "description": "Filter by procurement item status." } ], "tags": [ - "Procurement" - ], - "security": [ - { - "jwtAuth": [] - } + "Social auth" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OidcBrowserStart" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/OidcBrowserStart" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/OidcBrowserStart" + } + } + }, + "required": true + }, "responses": { "200": { "content": { - "text/csv": { - "schema": { - "type": "string" - } - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/OidcBrowserStartResponse" } } }, @@ -62079,64 +62681,80 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request." - }, - "401": { - "description": "Authentication credentials were not provided." + "description": "" }, "403": { - "description": "Permission denied." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Not found." - } - } - } - }, - "/api/v1/procurement/makerspace/{makerspace_id}/to-buy/machine-types": { - "get": { - "operationId": "api_v1_procurement_makerspace_to_buy_machine_types_retrieve", - "summary": "List machine types available for a new to-buy item", - "parameters": [ - { - "in": "query", - "name": "kind", - "schema": { - "type": "string", - "enum": [ - "hardware", - "printing" - ] + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "description": "Stream to add to. Honored only for makerspace admins/superadmin; other roles are auto-tagged by role." + "description": "" }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true + "description": "" + }, + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." } - ], + } + } + }, + "/api/v1/auth/social/oidc/callback": { + "post": { + "operationId": "api_v1_auth_social_oidc_callback_create", "tags": [ - "Procurement" - ], - "security": [ - { - "jwtAuth": [] - } + "Social auth" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OidcBrowserCallback" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/OidcBrowserCallback" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/OidcBrowserCallback" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToBuyMachineTypeOptions" + "$ref": "#/components/schemas/OidcBrowserLoginResponse" } } }, @@ -62146,40 +62764,63 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request." + "description": "" }, "401": { - "description": "Authentication credentials were not provided." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "Permission denied." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, - "404": { - "description": "Not found." + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." } } } }, - "/api/v1/procurement/to-buy/{id}": { + "/api/v1/auth/social/providers": { "get": { - "operationId": "api_v1_procurement_to_buy_retrieve", - "summary": "Retrieve a to-buy item", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_auth_social_providers_list", "tags": [ - "Procurement" + "Social auth" ], "security": [ { @@ -62191,67 +62832,47 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToBuyItem" + "type": "array", + "items": { + "$ref": "#/components/schemas/SocialIdentity" + } } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Invalid request." - }, "401": { - "description": "Authentication credentials were not provided." + "description": "Not signed in." }, "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." } } }, - "patch": { - "operationId": "api_v1_procurement_to_buy_partial_update", - "summary": "Update a to-buy item", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "post": { + "operationId": "api_v1_auth_social_providers_create", "tags": [ - "Procurement" + "Social auth" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedToBuyItem" + "$ref": "#/components/schemas/SocialLink" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedToBuyItem" + "$ref": "#/components/schemas/SocialLink" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedToBuyItem" + "$ref": "#/components/schemas/SocialLink" } } - } + }, + "required": true }, "security": [ { @@ -62263,48 +62884,48 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToBuyItem" + "$ref": "#/components/schemas/SocialIdentity" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Invalid request." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "description": "Authentication credentials were not provided." + "description": "Not signed in." }, "403": { - "description": "Permission denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "description": "Not found." + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "409": { + "description": "Conflicts with the current state." + }, + "503": { + "description": "A required dependency is unavailable." } } - }, + } + }, + "/api/v1/auth/social/providers/{provider}": { "delete": { - "operationId": "api_v1_procurement_to_buy_destroy", - "summary": "Delete a to-buy item", + "operationId": "api_v1_auth_social_providers_destroy", "parameters": [ { "in": "path", - "name": "id", + "name": "provider", "schema": { - "type": "integer" + "type": "string" }, "required": true } ], "tags": [ - "Procurement" + "Social auth" ], "security": [ { @@ -62315,198 +62936,177 @@ "204": { "description": "No response body" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Invalid request." - }, "401": { - "description": "Authentication credentials were not provided." + "description": "Not signed in." }, "403": { - "description": "Permission denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "description": "Not found." + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "409": { + "description": "Conflicts with the current state." } } } }, - "/api/v1/procurement/to-buy/{id}/move-to-inventory": { + "/api/v1/auth/staff-invitations/accept": { "post": { - "operationId": "api_v1_procurement_to_buy_move_to_inventory_create", - "summary": "Move a received hardware to-buy item into inventory", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_auth_staff_invitations_accept_create", "tags": [ - "Procurement" + "Staff invitations" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MoveToInventoryRequest" + "$ref": "#/components/schemas/StaffInvitationAccept" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MoveToInventoryRequest" + "$ref": "#/components/schemas/StaffInvitationAccept" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MoveToInventoryRequest" + "$ref": "#/components/schemas/StaffInvitationAccept" } } }, "required": true }, - "security": [ - { - "jwtAuth": [] - } - ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToBuyItem" + "$ref": "#/components/schemas/StaffInvitationAccepted" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Invalid request." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "description": "Authentication credentials were not provided." + "description": "Not signed in." }, "403": { - "description": "Permission denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "404": { - "description": "Not found." + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." + }, + "503": { + "description": "A required dependency is unavailable." } } } }, - "/api/v1/procurement/to-buy/{id}/move-to-printing": { - "post": { - "operationId": "api_v1_procurement_to_buy_move_to_printing_create", - "summary": "Move a received printing to-buy item into printing assets", + "/api/v1/backups/download/{archive_id}/{token}": { + "get": { + "operationId": "api_v1_backups_download_retrieve", + "summary": "Consume a one-use backup download", "parameters": [ { "in": "path", - "name": "id", + "name": "archive_id", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "required": true - } - ], - "tags": [ - "Procurement" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MoveToPrintingRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/MoveToPrintingRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/MoveToPrintingRequest" - } - } }, - "required": true - }, - "security": [ { - "jwtAuth": [] + "in": "path", + "name": "token", + "schema": { + "type": "string" + }, + "required": true } ], + "tags": [ + "Backup" + ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ToBuyItem" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Invalid request." - }, - "401": { - "description": "Authentication credentials were not provided." - }, - "403": { - "description": "Permission denied." + "description": "Age-encrypted archive stream." }, "404": { - "description": "Not found." + "description": "Invalid or expired download." } } } }, - "/api/v1/procurement/to-buy/{id}/receipts": { + "/api/v1/booking-integration/bookings/": { "get": { - "operationId": "api_v1_procurement_to_buy_receipts_list", - "summary": "List procurement receipts for a to-buy item", + "operationId": "api_v1_booking_integration_bookings_retrieve", + "description": "Requires bookings:read; bookings:attendance:read adds current unreversed arrived_at, including arrivals retained after completion. No contacts, answers, notes, credentials or payment data. GET and HEAD audit the disclosure under the tenant source fence.", + "summary": "Read minimal tenant reservations (server HMAC; bookings:read)", "parameters": [ { - "in": "path", - "name": "id", + "in": "header", + "name": "X-Client-Id", "schema": { - "type": "integer" + "type": "string" + }, + "required": true + }, + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Required when nonce enforcement is enabled." + }, + { + "in": "header", + "name": "X-Signature", + "schema": { + "type": "string" + }, + "description": "HMAC-SHA256 of method, full path, timestamp, optional nonce and raw body.", + "required": true + }, + { + "in": "header", + "name": "X-Timestamp", + "schema": { + "type": "string" }, "required": true + }, + { + "in": "query", + "name": "cursor", + "schema": { + "type": "string" + }, + "description": "Opaque, signed continuation cursor." + }, + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer" + }, + "description": "1–200; default 100." } ], "tags": [ - "Procurement" + "Server bookings" ], "security": [ { - "jwtAuth": [] + "ServerHMAC": [] } ], "responses": { @@ -62514,10 +63114,7 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ToBuyReceipt" - } + "$ref": "#/components/schemas/IntegrationBookingPage" } } }, @@ -62527,105 +63124,114 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request." + "description": "Invalid cursor or page size." }, "401": { - "description": "Authentication credentials were not provided." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Missing or invalid HMAC signature." }, "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." - } - } - }, - "post": { - "operationId": "api_v1_procurement_to_buy_receipts_create", - "summary": "Finalize an uploaded procurement receipt", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Procurement" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ToBuyReceiptFinalize" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ToBuyReceiptFinalize" + "description": "Server type, tenant binding or required scopes missing." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ToBuyReceiptFinalize" - } - } + "description": "Tenant/subject/module/feature unavailable." }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "201": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToBuyReceipt" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Read rate exceeded." }, - "400": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request." + "description": "Source tenant is frozen; audited reads cannot append history." }, "503": { - "description": "Receipt storage is unavailable." - }, - "401": { - "description": "Authentication credentials were not provided." - }, - "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Source gate could not be proven available." } } } }, - "/api/v1/procurement/to-buy/{id}/receipts/presign": { - "post": { - "operationId": "api_v1_procurement_to_buy_receipts_presign_create", - "summary": "Create a procurement receipt upload URL", + "/api/v1/booking-integration/bookings/{id}/": { + "get": { + "operationId": "api_v1_booking_integration_bookings_retrieve_2", + "description": "Requires bookings:read; bookings:attendance:read adds current unreversed arrived_at, including arrivals retained after completion. No contacts, answers, notes, credentials or payment data. GET and HEAD audit the disclosure under the tenant source fence.", + "summary": "Read one tenant reservation (server HMAC; bookings:read)", "parameters": [ + { + "in": "header", + "name": "X-Client-Id", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Required when nonce enforcement is enabled." + }, + { + "in": "header", + "name": "X-Signature", + "schema": { + "type": "string" + }, + "description": "HMAC-SHA256 of method, full path, timestamp, optional nonce and raw body.", + "required": true + }, + { + "in": "header", + "name": "X-Timestamp", + "schema": { + "type": "string" + }, + "required": true + }, { "in": "path", "name": "id", @@ -62636,39 +63242,19 @@ } ], "tags": [ - "Procurement" + "Server bookings" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ToBuyReceiptPresign" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ToBuyReceiptPresign" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ToBuyReceiptPresign" - } - } - }, - "required": true - }, "security": [ { - "jwtAuth": [] + "ServerHMAC": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToBuyReceiptUploadResponse" + "$ref": "#/components/schemas/IntegrationBooking" } } }, @@ -62678,220 +63264,150 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request." - }, - "503": { - "description": "Receipt storage is unavailable." + "description": "Invalid cursor or page size." }, "401": { - "description": "Authentication credentials were not provided." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Missing or invalid HMAC signature." }, "403": { - "description": "Permission denied." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Server type, tenant binding or required scopes missing." }, "404": { - "description": "Not found." - } - } - } - }, - "/api/v1/procurement/to-buy/receipts/{id}": { - "delete": { - "operationId": "api_v1_procurement_to_buy_receipts_destroy", - "summary": "Delete a procurement receipt", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true - } - ], - "tags": [ - "Procurement" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "204": { - "description": "No response body" + "description": "Tenant/subject/module/feature unavailable." }, - "400": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request." - }, - "401": { - "description": "Authentication credentials were not provided." - }, - "403": { - "description": "Permission denied." + "description": "Read rate exceeded." }, - "404": { - "description": "Not found." - } - } - } - }, - "/api/v1/procurement/to-buy/receipts/{id}/url": { - "get": { - "operationId": "api_v1_procurement_to_buy_receipts_url_retrieve", - "summary": "Create a signed procurement receipt view URL", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Procurement" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToBuyReceiptUrl" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Source tenant is frozen; audited reads cannot append history." }, "503": { - "description": "Receipt storage is unavailable." - }, - "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request." - }, - "401": { - "description": "Authentication credentials were not provided." - }, - "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." + "description": "Source gate could not be proven available." } } } }, - "/api/v1/public/{makerspace_slug}/bookings/calendar/{raw_token}.ics": { + "/api/v1/booking-integration/resources/": { "get": { - "operationId": "api_v1_public_bookings_calendar_.ics_retrieve", + "operationId": "api_v1_booking_integration_resources_retrieve", + "summary": "Read tenant resources (server HMAC; booking_resources:read)", "parameters": [ { - "in": "path", - "name": "makerspace_slug", + "in": "header", + "name": "X-Client-Id", "schema": { "type": "string" }, "required": true }, { - "in": "path", - "name": "raw_token", + "in": "header", + "name": "X-Nonce", "schema": { "type": "string" }, - "required": true - } - ], - "tags": [ - "Member bookings" - ], - "responses": { - "200": { - "content": { - "text/calendar": { - "schema": { - "type": "string" - } - } - }, - "description": "Own reservations as RFC 5545 ICS." - }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, - "403": { - "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." - }, - "404": { - "description": "Not found, not visible to this actor, or the surface is not enabled here." + "description": "Required when nonce enforcement is enabled." }, - "429": { - "description": "Rate limit exceeded; retry after the interval in Retry-After." - } - } - } - }, - "/api/v1/public/{makerspace_slug}/event-calendar/{raw_token}.ics": { - "get": { - "operationId": "api_v1_public_event_calendar_.ics_retrieve", - "parameters": [ { - "in": "path", - "name": "makerspace_slug", + "in": "header", + "name": "X-Signature", "schema": { "type": "string" }, + "description": "HMAC-SHA256 of method, full path, timestamp, optional nonce and raw body.", "required": true }, { - "in": "path", - "name": "raw_token", + "in": "header", + "name": "X-Timestamp", "schema": { "type": "string" }, "required": true + }, + { + "in": "query", + "name": "cursor", + "schema": { + "type": "string" + }, + "description": "Opaque, signed continuation cursor." + }, + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer" + }, + "description": "1–200; default 100." } ], "tags": [ - "Public events" + "Server bookings" + ], + "security": [ + { + "ServerHMAC": [] + } ], "responses": { "200": { "content": { - "text/calendar": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/IntegrationBookingResourcePage" } } }, - "description": "RFC 5545 calendar (`text/calendar; charset=utf-8`)." + "description": "" }, - "404": { + "400": { "content": { "application/json": { "schema": { @@ -62899,9 +63415,9 @@ } } }, - "description": "Calendar not found." + "description": "Invalid cursor or page size." }, - "429": { + "401": { "content": { "application/json": { "schema": { @@ -62909,106 +63425,94 @@ } } }, - "description": "Rate limit exceeded." - } - } - } - }, - "/api/v1/public/{makerspace_slug}/events/": { - "get": { - "operationId": "api_v1_public_events_list", - "parameters": [ - { - "in": "query", - "name": "category", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" - }, - "required": true + "description": "Missing or invalid HMAC signature." }, - { - "in": "query", - "name": "q", - "schema": { - "type": "string" + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "description": "Full-text search over title, location and description; supports \"phrases\" and -exclusions, tolerates typos in the title." + "description": "Server type, tenant binding or required scopes missing." }, - { - "in": "query", - "name": "tag", - "schema": { - "type": "string" - } - } - ], - "tags": [ - "Public events" - ], - "responses": { - "200": { + "404": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicEvent" - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Tenant/subject/module/feature unavailable." }, - "400": { + "429": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request." + "description": "Read rate exceeded." }, - "404": { + "423": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Event not found." + "description": "Source tenant is frozen; audited reads cannot append history." }, - "429": { + "503": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Rate limit exceeded." + "description": "Source gate could not be proven available." } } } }, - "/api/v1/public/{makerspace_slug}/events/{public_token}/": { + "/api/v1/booking-integration/resources/{id}/": { "get": { - "operationId": "api_v1_public_events_retrieve", + "operationId": "api_v1_booking_integration_resources_retrieve_2", + "summary": "Read one tenant resource (server HMAC; booking_resources:read)", "parameters": [ { - "in": "path", - "name": "makerspace_slug", + "in": "header", + "name": "X-Client-Id", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Required when nonce enforcement is enabled." + }, + { + "in": "header", + "name": "X-Signature", + "schema": { + "type": "string" + }, + "description": "HMAC-SHA256 of method, full path, timestamp, optional nonce and raw body.", + "required": true + }, + { + "in": "header", + "name": "X-Timestamp", "schema": { "type": "string" }, @@ -63016,23 +63520,27 @@ }, { "in": "path", - "name": "public_token", + "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Public events" + "Server bookings" + ], + "security": [ + { + "ServerHMAC": [] + } ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicEvent" + "$ref": "#/components/schemas/IntegrationBookingResource" } } }, @@ -63042,76 +63550,43 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request." + "description": "Invalid cursor or page size." }, - "404": { + "401": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Event not found." + "description": "Missing or invalid HMAC signature." }, - "429": { + "403": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Rate limit exceeded." - } - } - } - }, - "/api/v1/public/{makerspace_slug}/events/{public_token}/calendar.ics": { - "get": { - "operationId": "api_v1_public_events_calendar.ics_retrieve", - "parameters": [ - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" - }, - "required": true + "description": "Server type, tenant binding or required scopes missing." }, - { - "in": "path", - "name": "public_token", - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true - } - ], - "tags": [ - "Public events" - ], - "responses": { - "200": { + "404": { "content": { - "text/calendar": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "RFC 5545 calendar (`text/calendar; charset=utf-8`)." + "description": "Tenant/subject/module/feature unavailable." }, - "400": { + "429": { "content": { "application/json": { "schema": { @@ -63119,9 +63594,9 @@ } } }, - "description": "Events module unavailable." + "description": "Read rate exceeded." }, - "404": { + "423": { "content": { "application/json": { "schema": { @@ -63129,9 +63604,9 @@ } } }, - "description": "Calendar not found." + "description": "Source tenant is frozen; audited reads cannot append history." }, - "429": { + "503": { "content": { "application/json": { "schema": { @@ -63139,42 +63614,89 @@ } } }, - "description": "Rate limit exceeded." + "description": "Source gate could not be proven available." } } } }, - "/api/v1/public/{makerspace_slug}/events/{public_token}/feedback/": { + "/api/v1/booking-integration/resources/{id}/bookings/": { "get": { - "operationId": "api_v1_public_events_feedback_retrieve", + "operationId": "api_v1_booking_integration_resources_bookings_retrieve", + "description": "Requires bookings:read; bookings:attendance:read adds current unreversed arrived_at, including arrivals retained after completion. No contacts, answers, notes, credentials or payment data. GET and HEAD audit the disclosure under the tenant source fence.", + "summary": "Read reservations for one resource (server HMAC; bookings:read)", "parameters": [ { - "in": "path", - "name": "makerspace_slug", + "in": "header", + "name": "X-Client-Id", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Required when nonce enforcement is enabled." + }, + { + "in": "header", + "name": "X-Signature", + "schema": { + "type": "string" + }, + "description": "HMAC-SHA256 of method, full path, timestamp, optional nonce and raw body.", + "required": true + }, + { + "in": "header", + "name": "X-Timestamp", "schema": { "type": "string" }, "required": true }, + { + "in": "query", + "name": "cursor", + "schema": { + "type": "string" + }, + "description": "Opaque, signed continuation cursor." + }, { "in": "path", - "name": "public_token", + "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer" + }, + "description": "1–200; default 100." } ], "tags": [ - "Public events" + "Server bookings" + ], + "security": [ + { + "ServerHMAC": [] + } ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FeedbackForm" + "$ref": "#/components/schemas/IntegrationBookingPage" } } }, @@ -63188,7 +63710,7 @@ } } }, - "description": "Invalid feedback answers." + "description": "Invalid cursor or page size." }, "401": { "content": { @@ -63198,9 +63720,9 @@ } } }, - "description": "Authentication is required for a certificate." + "description": "Missing or invalid HMAC signature." }, - "404": { + "403": { "content": { "application/json": { "schema": { @@ -63208,9 +63730,9 @@ } } }, - "description": "Feedback form not found." + "description": "Server type, tenant binding or required scopes missing." }, - "409": { + "404": { "content": { "application/json": { "schema": { @@ -63218,7 +63740,7 @@ } } }, - "description": "Feedback retry conflict." + "description": "Tenant/subject/module/feature unavailable." }, "429": { "content": { @@ -63228,53 +63750,54 @@ } } }, - "description": "Rate limit exceeded." + "description": "Read rate exceeded." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Source tenant is frozen; audited reads cannot append history." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Source gate could not be proven available." } } - }, - "post": { - "operationId": "api_v1_public_events_feedback_create", + } + }, + "/api/v1/bootstrap": { + "get": { + "operationId": "api_v1_bootstrap_retrieve", + "summary": "Resolve tenant and frontend-safe configuration", "parameters": [ { - "in": "path", - "name": "makerspace_slug", + "in": "query", + "name": "slug", "schema": { "type": "string" - }, - "required": true + } }, { - "in": "path", - "name": "public_token", + "in": "query", + "name": "tenant", "schema": { - "type": "string", - "format": "uuid" - }, - "required": true + "type": "string" + } } ], "tags": [ - "Public events" + "Tenant bootstrap" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FeedbackSubmission" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/FeedbackSubmission" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/FeedbackSubmission" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -63282,84 +63805,113 @@ {} ], "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FeedbackSubmissionResponse" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid feedback answers." - }, - "401": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TenantBootstrap" } } }, - "description": "Authentication is required for a certificate." + "description": "Frontend-safe tenant bootstrap payload." }, "404": { + "description": "No active tenant frontend matched." + } + } + } + }, + "/api/v1/config": { + "get": { + "operationId": "api_v1_config_retrieve", + "description": "Includes native minimum app versions and public provider settings only while the mobile module permits new grants.", + "summary": "Return frontend-safe platform configuration", + "tags": [ + "Platform" + ], + "security": [ + {} + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PublicConfig" } } }, - "description": "Feedback form not found." + "description": "" }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Feedback retry conflict." + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Rate limit exceeded." + "503": { + "description": "A required dependency is unavailable." } } } }, - "/api/v1/public/{makerspace_slug}/events/{public_token}/register/": { - "post": { - "operationId": "api_v1_public_events_register_create", + "/api/v1/data-exports/download/{job_id}/{token}": { + "get": { + "operationId": "api_v1_data_exports_download_retrieve", + "summary": "Consume a one-use export download URL", "parameters": [ { "in": "path", - "name": "makerspace_slug", + "name": "job_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + }, + { + "in": "path", + "name": "token", "schema": { "type": "string" }, "required": true + } + ], + "tags": [ + "Data exports" + ], + "responses": { + "200": { + "content": { + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "" + }, + "404": { + "description": "Download link is invalid, expired, or used." }, + "503": { + "description": "Archive storage is unavailable." + } + } + } + }, + "/api/v1/device/commands/{id}/ack": { + "post": { + "operationId": "api_v1_device_commands_ack_create", + "parameters": [ { "in": "path", - "name": "public_token", + "name": "id", "schema": { "type": "string", "format": "uuid" @@ -63368,38 +63920,39 @@ } ], "tags": [ - "Public events" + "Device protocol" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicEventRegistrationInput" + "$ref": "#/components/schemas/Ack" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PublicEventRegistrationInput" + "$ref": "#/components/schemas/Ack" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PublicEventRegistrationInput" + "$ref": "#/components/schemas/Ack" } } - } + }, + "required": true }, "security": [ { - "jwtAuth": [] + "DeviceSignature": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicEventRegistrationResponse" + "$ref": "#/components/schemas/CommandAck" } } }, @@ -63409,153 +63962,92 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Invalid request." + "description": "Invalid payload or adapter module disabled." }, - "404": { + "401": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Event not found." + "description": "Authentication/signature/replay refused." }, - "429": { + "403": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Rate limit exceeded." + "description": "Authority or device revoked." }, - "401": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Authentication is required." + "description": "Target outside scope or absent." }, - "403": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "An active authenticated account is required; member audiences also require active membership and the current waiver." + "description": "Conflicting command/report." }, - "409": { + "410": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Event state conflict or cancelled paid registration cannot be reused." + "description": "Delivery lease expired." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Tenant frozen for migration." } } } }, - "/api/v1/public/{makerspace_slug}/inventory/": { - "get": { - "operationId": "api_v1_public_inventory_list", - "description": "List public inventory products for a public makerspace.", - "summary": "List public inventory products", - "parameters": [ - { - "in": "header", - "name": "X-Nonce", - "schema": { - "type": "string" - }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." - }, - { - "in": "header", - "name": "X-Publishable-Key", - "schema": { - "type": "string" - }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." - }, - { - "in": "query", - "name": "category", - "schema": { - "type": "string" - }, - "description": "Filter public products by category slug." - }, - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" - }, - "description": "Public makerspace code (for example TSEL) or slug.", - "required": true - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } - }, - { - "in": "query", - "name": "q", - "schema": { - "type": "string" - }, - "description": "Full-text search over public name and description; supports \"phrases\" and -exclusions, tolerates typos in the name." - }, - { - "in": "query", - "name": "sort", - "schema": { - "type": "string", - "enum": [ - "most_used", - "name", - "popular" - ] - }, - "description": "Sort public products." - } - ], + "/api/v1/device/commands/lease": { + "post": { + "operationId": "api_v1_device_commands_lease_create", "tags": [ - "Public inventory" + "Device protocol" ], "security": [ { - "jwtAuth": [] - }, - {} + "DeviceSignature": [] + } ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedPublicProductList" + "$ref": "#/components/schemas/LeaseResponse" } } }, @@ -63565,109 +64057,119 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicInventoryError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Public inventory module or loans feature is disabled." + "description": "Invalid payload or adapter module disabled." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicInventoryError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Invalid authentication credentials." + "description": "Authentication/signature/replay refused." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicInventoryError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Public API client is not authorized." + "description": "Authority or device revoked." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicInventoryError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Item or public makerspace not found, or hidden by edition." + "description": "Target outside scope or absent." }, - "429": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicInventoryError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Public read rate limit exceeded." + "description": "Conflicting command/report." + }, + "410": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Delivery lease expired." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Tenant frozen for migration." } } } }, - "/api/v1/public/{makerspace_slug}/inventory/{id}/": { - "get": { - "operationId": "api_v1_public_inventory_retrieve", - "description": "Read-only public catalogue detail. Private, archived and cross-space items share the same not-found response.", - "summary": "Get public inventory product detail", + "/api/v1/device/enrollments/{id}/challenge": { + "post": { + "operationId": "api_v1_device_enrollments_challenge_create", + "summary": "Request a proof-of-possession challenge for a candidate key", "parameters": [ - { - "in": "header", - "name": "X-Nonce", - "schema": { - "type": "string" - }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." - }, - { - "in": "header", - "name": "X-Publishable-Key", - "schema": { - "type": "string" - }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." - }, { "in": "path", "name": "id", "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Public inventory" + "Device protocol" ], - "security": [ - { - "jwtAuth": [] + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChallengeRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ChallengeRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ChallengeRequest" + } + } }, - {} - ], + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicProduct" + "$ref": "#/components/schemas/ChallengeResponse" } } }, @@ -63677,166 +64179,215 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicInventoryError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Public inventory module or loans feature is disabled." + "description": "Malformed key, challenge or signature, or module disabled." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicInventoryError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Invalid authentication credentials." + "description": "Not authenticated." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicInventoryError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Public API client is not authorized." + "description": "Enrollment credentials or proof of possession rejected." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicInventoryError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Item or public makerspace not found, or hidden by edition." + "description": "Not found." }, - "429": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicInventoryError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Public read rate limit exceeded." + "description": "The key is already bound to another device, or rotation reused the key." + }, + "410": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "The enrollment was used, revoked or has expired." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Tenant frozen for migration." } } } }, - "/api/v1/public/{makerspace_slug}/inventory/{id}/watch/": { + "/api/v1/device/enrollments/{id}/complete": { "post": { - "operationId": "api_v1_public_inventory_watch_create", + "operationId": "api_v1_device_enrollments_complete_create", + "summary": "Complete enrollment by signing the challenge with the candidate key", "parameters": [ - { - "in": "header", - "name": "X-Nonce", - "schema": { - "type": "string" - }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." - }, - { - "in": "header", - "name": "X-Publishable-Key", - "schema": { - "type": "string" - }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." - }, { "in": "path", "name": "id", "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "api" - ], - "security": [ - { - "jwtAuth": [] - } + "Device protocol" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompleteRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/CompleteRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/CompleteRequest" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WatchResponse" + "$ref": "#/components/schemas/ConnectedDeviceIdentity" } } }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Malformed key, challenge or signature, or module disabled." + }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Not authenticated." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Enrollment credentials or proof of possession rejected." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Not found." }, - "423": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "The key is already bound to another device, or rotation reused the key." }, - "429": { + "410": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "The enrollment was used, revoked or has expired." }, - "503": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Tenant frozen for migration." + } + } + } + }, + "/api/v1/device/heartbeat": { + "post": { + "operationId": "api_v1_device_heartbeat_create", + "summary": "Report device activity and recover an offline device (signed request)", + "tags": [ + "Device protocol" + ], + "security": [ + { + "DeviceSignature": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectedDeviceIdentity" } } }, @@ -63846,185 +64397,213 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" - } - } - }, - "delete": { - "operationId": "api_v1_public_inventory_watch_destroy", - "parameters": [ - { - "in": "header", - "name": "X-Nonce", - "schema": { - "type": "string" - }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + "description": "Module disabled." }, - { - "in": "header", - "name": "X-Publishable-Key", - "schema": { - "type": "string" + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + "description": "Missing, malformed, stale, replayed or invalid signature." }, - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } }, - "required": true + "description": "Device not allowed on this route, revoked or tenant unavailable." }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Tenant frozen for migration." + } + } + } + }, + "/api/v1/device/label-commands/{id}/content": { + "get": { + "operationId": "api_v1_device_label_commands_content_retrieve", + "summary": "Download acknowledged label ZIP", + "parameters": [ { "in": "path", - "name": "makerspace_slug", + "name": "id", "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "api" + "Device protocol" ], "security": [ { - "jwtAuth": [] + "DeviceSignature": [] } ], "responses": { - "204": { - "description": "No response body" + "200": { + "content": { + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] } } }, - "description": "" + "description": "DRF field/module validation errors or non-field validation messages." }, - "403": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Authentication required or invalid signature." }, - "404": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Permission denied." }, - "423": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Command or batch not in scope." }, - "429": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Idempotency or delivery conflict." }, - "503": { + "410": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Batch no longer available." }, - "400": { + "423": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Tenant frozen for migration." } } } }, - "/api/v1/public/{makerspace_slug}/inventory/categories/": { - "get": { - "operationId": "api_v1_public_inventory_categories_list", - "summary": "List public inventory categories", + "/api/v1/device/label-commands/{id}/result": { + "post": { + "operationId": "api_v1_device_label_commands_result_create", + "summary": "Report an idempotent label result", "parameters": [ - { - "in": "header", - "name": "X-Nonce", - "schema": { - "type": "string" - }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." - }, - { - "in": "header", - "name": "X-Publishable-Key", - "schema": { - "type": "string" - }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." - }, { "in": "path", - "name": "makerspace_slug", + "name": "id", "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Public inventory" + "Device protocol" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabelResult" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/LabelResult" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/LabelResult" + } + } + }, + "required": true + }, "security": [ { - "jwtAuth": [] - }, - {} + "DeviceSignature": [] + } ], "responses": { "200": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicCategory" - } + "$ref": "#/components/schemas/LabelReceipt" } } }, @@ -64034,87 +64613,118 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicInventoryError" + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] } } }, - "description": "Public inventory module or loans feature is disabled." + "description": "DRF field/module validation errors or non-field validation messages." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicInventoryError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Invalid authentication credentials." + "description": "Authentication required or invalid signature." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicInventoryError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Public API client is not authorized." + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicInventoryError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Item or public makerspace not found, or hidden by edition." + "description": "Command or batch not in scope." }, - "429": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicInventoryError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Public read rate limit exceeded." + "description": "Idempotency or delivery conflict." + }, + "410": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Batch no longer available." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Tenant frozen for migration." } } } }, - "/api/v1/public/{makerspace_slug}/invitation-requests": { + "/api/v1/device/printer-jobs/{id}/result": { "post": { - "operationId": "api_v1_public_invitation_requests_create", - "summary": "Ask a makerspace to invite you", + "operationId": "api_v1_device_printer_jobs_result_create", "parameters": [ { "in": "path", - "name": "makerspace_slug", + "name": "id", "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Memberships" + "Device protocol" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvitationRequestCreate" + "$ref": "#/components/schemas/Result" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/InvitationRequestCreate" + "$ref": "#/components/schemas/Result" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/InvitationRequestCreate" + "$ref": "#/components/schemas/Result" } } }, @@ -64122,16 +64732,15 @@ }, "security": [ { - "jwtAuth": [] - }, - {} + "DeviceSignature": [] + } ], "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvitationRequestAck" + "$ref": "#/components/schemas/Job" } } }, @@ -64141,80 +64750,107 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Invalid payload or adapter module disabled." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Authentication/signature/replay refused." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Authority or device revoked." }, "404": { - "description": "Unknown makerspace." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Target outside scope or absent." }, - "423": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Conflicting command/report." }, - "429": { + "410": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Delivery lease expired." }, - "503": { + "423": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "" + "description": "Tenant frozen for migration." } } } }, - "/api/v1/public/{makerspace_slug}/machine-service-requests": { + "/api/v1/device/printer-jobs/{id}/telemetry": { "post": { - "operationId": "api_v1_public_machine_service_requests_create", - "summary": "Submit a machine service request as a member", + "operationId": "api_v1_device_printer_jobs_telemetry_create", "parameters": [ { "in": "path", - "name": "makerspace_slug", + "name": "id", "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Public machine service" + "Device protocol" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicMachineServiceSubmit" + "$ref": "#/components/schemas/Telemetry" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PublicMachineServiceSubmit" + "$ref": "#/components/schemas/Telemetry" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PublicMachineServiceSubmit" + "$ref": "#/components/schemas/Telemetry" } } }, @@ -64222,15 +64858,15 @@ }, "security": [ { - "jwtAuth": [] + "DeviceSignature": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicMachineServiceSubmitResponse" + "$ref": "#/components/schemas/Job" } } }, @@ -64240,293 +64876,354 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Invalid machine service request input." + "description": "Invalid payload or adapter module disabled." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Authentication is required." + "description": "Authentication/signature/replay refused." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Active membership, waiver acceptance, and presence are required." + "description": "Authority or device revoked." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Makerspace or machine not found." + "description": "Target outside scope or absent." }, "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Machine service request conflict." + "description": "Conflicting command/report." }, - "429": { + "410": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceError" } } }, - "description": "Request rate limit exceeded." + "description": "Delivery lease expired." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceError" + } + } + }, + "description": "Tenant frozen for migration." } } } }, - "/api/v1/public/{makerspace_slug}/machine-service/3d-printer/consumable-pools": { + "/api/v1/discover/{kind}/": { "get": { - "operationId": "api_v1_public_machine_service_3d_printer_consumable_pools_list", + "operationId": "api_v1_discover_retrieve", "parameters": [ + { + "in": "query", + "name": "date_from", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "date_to", + "schema": { + "type": "string", + "format": "date" + } + }, { "in": "path", - "name": "makerspace_slug", + "name": "kind", "schema": { "type": "string" }, "required": true + }, + { + "in": "query", + "name": "kind", + "schema": { + "type": "string" + }, + "description": "Booking resource kind (query filter); events use date filters instead." + }, + { + "in": "query", + "name": "location", + "schema": { + "type": "string" + }, + "description": "Booking resource location contains this text." + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string", + "maxLength": 200 + } } ], "tags": [ - "Public machine service" + "Public websites" ], "responses": { "200": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicPrinterPool" - } + "$ref": "#/components/schemas/Discovery" } } }, "description": "" - } - } - } - }, - "/api/v1/public/{makerspace_slug}/machine-service/3d-printer/queues": { - "get": { - "operationId": "api_v1_public_machine_service_3d_printer_queues_list", - "parameters": [ - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true - } - ], - "tags": [ - "Public machine service" - ], - "responses": { - "200": { + "description": "Invalid discovery filters." + }, + "404": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicPrinterQueue" - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Unknown or unavailable public content." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." } } } }, - "/api/v1/public/{makerspace_slug}/machine-service/3d-printer/requests": { - "post": { - "operationId": "api_v1_public_machine_service_3d_printer_requests_create", + "/api/v1/event-checkin-stations/{public_token}/roster/": { + "get": { + "operationId": "api_v1_event_checkin_stations_roster_retrieve", + "summary": "Download the station's minimal expiring attendee roster", "parameters": [ { "in": "path", - "name": "makerspace_slug", + "name": "public_token", "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Public machine service" + "Event check-in stations" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicPrinterSubmit" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PublicPrinterSubmit" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PublicPrinterSubmit" - } - } - }, - "required": true - }, "security": [ { - "jwtAuth": [] - } + "EventStationCookie": [] + }, + {} ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicPrinterSubmitResponse" + "$ref": "#/components/schemas/OfflineRosterResponse" } } }, "description": "" - } - } - } - }, - "/api/v1/public/{makerspace_slug}/machine-service/3d-printer/uploads": { - "post": { - "operationId": "api_v1_public_machine_service_3d_printer_uploads_create", + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid station credential, session, origin, feature, or time window." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid station credential, session, origin, feature, or time window." + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid station credential, session, origin, feature, or time window." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid station credential, session, origin, feature, or time window." + } + } + } + }, + "/api/v1/event-checkin-stations/{public_token}/session/": { + "post": { + "operationId": "api_v1_event_checkin_stations_session_create", + "summary": "Exchange an event-scoped PIN for a station session", "parameters": [ { "in": "path", - "name": "makerspace_slug", + "name": "public_token", "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Public machine service" + "Event check-in stations" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicPrinterUpload" + "$ref": "#/components/schemas/StationPin" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PublicPrinterUpload" + "$ref": "#/components/schemas/StationPin" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PublicPrinterUpload" + "$ref": "#/components/schemas/StationPin" } } }, "required": true }, - "security": [ - { - "jwtAuth": [] - } - ], "responses": { - "201": { + "204": { + "description": "No response body" + }, + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicPrinterSubmitResponse" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" - } - } - } - }, - "/api/v1/public/{makerspace_slug}/machine-types/{type_id}/intake": { - "get": { - "operationId": "api_v1_public_machine_types_intake_retrieve", - "parameters": [ - { - "in": "header", - "name": "X-Nonce", - "schema": { - "type": "string" - }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + "description": "Invalid station credential, session, origin, feature, or time window." }, - { - "in": "header", - "name": "X-Publishable-Key", - "schema": { - "type": "string" + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + "description": "Invalid station credential, session, origin, feature, or time window." }, - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true - }, + "description": "Invalid station credential, session, origin, feature, or time window." + } + } + }, + "delete": { + "operationId": "api_v1_event_checkin_stations_session_destroy", + "summary": "Clear the local station session cookie", + "parameters": [ { "in": "path", - "name": "type_id", + "name": "public_token", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "api" + "Event check-in stations" + ], + "security": [ + { + "EventStationCookie": [] + } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicTypeIntake" - } - } - }, - "description": "" + "204": { + "description": "No response body" }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -64534,9 +65231,9 @@ } } }, - "description": "" + "description": "Invalid station credential, session, origin, feature, or time window." }, - "403": { + "429": { "content": { "application/json": { "schema": { @@ -64544,19 +65241,67 @@ } } }, - "description": "" + "description": "Invalid station credential, session, origin, feature, or time window." + } + } + } + }, + "/api/v1/event-checkin-stations/{public_token}/sync/": { + "post": { + "operationId": "api_v1_event_checkin_stations_sync_create", + "summary": "Synchronize queued PIN-station check-ins", + "parameters": [ + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Event check-in stations" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OfflineCheckInSyncRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/OfflineCheckInSyncRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/OfflineCheckInSyncRequest" + } + } }, - "404": { + "required": true + }, + "security": [ + { + "EventStationCookie": [] + }, + {} + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/OfflineCheckInSyncResponse" } } }, "description": "" }, - "423": { + "400": { "content": { "application/json": { "schema": { @@ -64564,9 +65309,9 @@ } } }, - "description": "" + "description": "Invalid station credential, session, origin, feature, or time window." }, - "429": { + "403": { "content": { "application/json": { "schema": { @@ -64574,9 +65319,9 @@ } } }, - "description": "" + "description": "Invalid station credential, session, origin, feature, or time window." }, - "503": { + "410": { "content": { "application/json": { "schema": { @@ -64584,86 +65329,81 @@ } } }, - "description": "" + "description": "Invalid station credential, session, origin, feature, or time window." }, - "400": { + "429": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Invalid station credential, session, origin, feature, or time window." } } } }, - "/api/v1/public/{makerspace_slug}/machines": { + "/api/v1/events/": { "get": { - "operationId": "api_v1_public_machines_list", - "description": "List active machines published by a public makerspace.", - "summary": "List public machines", + "operationId": "api_v1_events_retrieve", + "summary": "Read minimal tenant events (server HMAC; events:read)", "parameters": [ { "in": "header", - "name": "X-Nonce", + "name": "X-Client-Id", "schema": { "type": "string" }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + "required": true }, { "in": "header", - "name": "X-Publishable-Key", + "name": "X-Nonce", "schema": { "type": "string" }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + "description": "Required when nonce enforcement is enabled." }, { - "in": "path", - "name": "makerspace_slug", + "in": "header", + "name": "X-Timestamp", "schema": { "type": "string" }, - "description": "Public makerspace code or slug.", "required": true }, { - "name": "page", - "required": false, "in": "query", - "description": "A page number within the paginated result set.", + "name": "cursor", "schema": { - "type": "integer" - } + "type": "string" + }, + "description": "Opaque, signed continuation cursor." }, { "in": "query", - "name": "q", + "name": "page_size", "schema": { - "type": "string" + "type": "integer" }, - "description": "Full-text search over name, location, firmware version and notes; tolerates typos in the name." + "description": "1–200; default 100." } ], "tags": [ - "Public machines" + "Server events" ], "security": [ { - "jwtAuth": [] - }, - {} + "ServerHMAC": [] + } ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedPublicMachineList" + "$ref": "#/components/schemas/IntegrationEventPage" } } }, @@ -64673,21 +65413,13 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "module": { - "type": "string" - } - }, - "required": [ - "module" - ] + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Machines module disabled." + "description": "Invalid cursor or page size." }, - "404": { + "401": { "content": { "application/json": { "schema": { @@ -64695,9 +65427,9 @@ } } }, - "description": "Public makerspace not found." + "description": "Missing or invalid HMAC signature." }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -64705,9 +65437,9 @@ } } }, - "description": "Invalid API credentials." + "description": "Server type, tenant binding or required scopes missing." }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -64715,7 +65447,7 @@ } } }, - "description": "API client not authorized." + "description": "Tenant/event/module/feature unavailable." }, "429": { "content": { @@ -64725,57 +65457,83 @@ } } }, - "description": "Rate limit exceeded." + "description": "Read rate exceeded." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Source tenant is frozen; audited reads cannot append history." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Source gate could not be proven available." } } } }, - "/api/v1/public/{makerspace_slug}/machines/{id}": { + "/api/v1/events/{id}/": { "get": { - "operationId": "api_v1_public_machines_retrieve", + "operationId": "api_v1_events_retrieve_2", + "summary": "Read one tenant event (server HMAC; events:read)", "parameters": [ { "in": "header", - "name": "X-Nonce", + "name": "X-Client-Id", "schema": { "type": "string" }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + "required": true }, { "in": "header", - "name": "X-Publishable-Key", + "name": "X-Nonce", "schema": { "type": "string" }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + "description": "Required when nonce enforcement is enabled." }, { - "in": "path", - "name": "id", + "in": "header", + "name": "X-Timestamp", "schema": { - "type": "integer" + "type": "string" }, "required": true }, { "in": "path", - "name": "makerspace_slug", + "name": "id", "schema": { - "type": "string" + "type": "integer" }, "required": true } ], "tags": [ - "api" + "Server events" + ], + "security": [ + { + "ServerHMAC": [] + } ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicMachineDetail" + "$ref": "#/components/schemas/IntegrationEvent" } } }, @@ -64789,7 +65547,7 @@ } } }, - "description": "" + "description": "Invalid cursor or page size." }, "401": { "content": { @@ -64799,7 +65557,7 @@ } } }, - "description": "" + "description": "Missing or invalid HMAC signature." }, "403": { "content": { @@ -64809,7 +65567,7 @@ } } }, - "description": "" + "description": "Server type, tenant binding or required scopes missing." }, "404": { "content": { @@ -64819,7 +65577,7 @@ } } }, - "description": "" + "description": "Tenant/event/module/feature unavailable." }, "429": { "content": { @@ -64829,58 +65587,100 @@ } } }, - "description": "" + "description": "Read rate exceeded." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Source tenant is frozen; audited reads cannot append history." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Source gate could not be proven available." } } } }, - "/api/v1/public/{makerspace_slug}/machines/details": { + "/api/v1/events/{id}/participants/": { "get": { - "operationId": "api_v1_public_machines_details_list", + "operationId": "api_v1_events_participants_retrieve", + "description": "Attendance dates are included only with participants:attendance:read. No contacts, answers, ticket/check-in tokens or payment fields.", + "summary": "Read minimal participants; events:read AND participants:read required", "parameters": [ + { + "in": "header", + "name": "X-Client-Id", + "schema": { + "type": "string" + }, + "required": true + }, { "in": "header", "name": "X-Nonce", "schema": { "type": "string" }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + "description": "Required when nonce enforcement is enabled." }, { "in": "header", - "name": "X-Publishable-Key", + "name": "X-Timestamp", "schema": { "type": "string" }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + "required": true }, { - "in": "path", - "name": "makerspace_slug", + "in": "query", + "name": "cursor", "schema": { "type": "string" }, + "description": "Opaque, signed continuation cursor." + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, "required": true }, { - "name": "page", - "required": false, "in": "query", - "description": "A page number within the paginated result set.", + "name": "page_size", "schema": { "type": "integer" - } + }, + "description": "1–200; default 100." } ], "tags": [ - "api" + "Server events" + ], + "security": [ + { + "ServerHMAC": [] + } ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedPublicMachineDetailList" + "$ref": "#/components/schemas/IntegrationParticipantPage" } } }, @@ -64894,7 +65694,7 @@ } } }, - "description": "" + "description": "Invalid cursor or page size." }, "401": { "content": { @@ -64904,7 +65704,7 @@ } } }, - "description": "" + "description": "Missing or invalid HMAC signature." }, "403": { "content": { @@ -64914,7 +65714,7 @@ } } }, - "description": "" + "description": "Server type, tenant binding or required scopes missing." }, "404": { "content": { @@ -64924,7 +65724,7 @@ } } }, - "description": "" + "description": "Tenant/event/module/feature unavailable." }, "429": { "content": { @@ -64934,33 +65734,97 @@ } } }, - "description": "" + "description": "Read rate exceeded." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Source tenant is frozen; audited reads cannot append history." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Source gate could not be proven available." } } } }, - "/api/v1/public/{makerspace_slug}/membership-admission": { + "/api/v1/guest-admin/makerspace/{makerspace_id}/active-loans": { "get": { - "operationId": "api_v1_public_membership_admission_retrieve", + "operationId": "api_v1_guest_admin_makerspace_active_loans_list", + "summary": "List active loans awaiting return", "parameters": [ + { + "name": "id", + "in": "query", + "required": false, + "description": "Select one result within the authorized list.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + } + }, { "in": "path", - "name": "makerspace_slug", + "name": "makerspace_id", "schema": { - "type": "string" + "type": "integer" }, "required": true + }, + { + "name": "ordering", + "required": false, + "in": "query", + "description": "Which field to use when ordering the results.", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "required": false, + "in": "query", + "description": "A search term (requested-for, requester name/email).", + "schema": { + "type": "string" + } } ], "tags": [ - "Memberships" + "Admin requests" + ], + "security": [ + { + "jwtAuth": [] + } ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicAdmission" + "$ref": "#/components/schemas/PaginatedAdminRequestList" } } }, @@ -64974,7 +65838,7 @@ } } }, - "description": "" + "description": "Invalid request." }, "401": { "content": { @@ -64984,7 +65848,7 @@ } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { @@ -64994,7 +65858,7 @@ } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { @@ -65004,75 +65868,47 @@ } } }, - "description": "" - }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not found." } } } }, - "/api/v1/public/{makerspace_slug}/membership-requests": { + "/api/v1/guest-admin/requests/{id}/return": { "post": { - "operationId": "api_v1_public_membership_requests_create", + "operationId": "api_v1_guest_admin_requests_return_create", + "summary": "Return issued request items", "parameters": [ { "in": "path", - "name": "makerspace_slug", + "name": "id", "schema": { - "type": "string" + "type": "integer" }, "required": true } ], "tags": [ - "Memberships" + "Admin requests" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipRequestCreate" + "$ref": "#/components/schemas/ReturnRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MembershipRequestCreate" + "$ref": "#/components/schemas/ReturnRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MembershipRequestCreate" + "$ref": "#/components/schemas/ReturnRequest" } } - } + }, + "required": true }, "security": [ { @@ -65080,11 +65916,11 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipOutcome" + "$ref": "#/components/schemas/AdminRequest" } } }, @@ -65098,7 +65934,7 @@ } } }, - "description": "" + "description": "Invalid request." }, "401": { "content": { @@ -65108,7 +65944,7 @@ } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { @@ -65118,7 +65954,7 @@ } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { @@ -65128,7 +65964,7 @@ } } }, - "description": "" + "description": "Not found." }, "409": { "content": { @@ -65138,9 +65974,9 @@ } } }, - "description": "" + "description": "Workflow conflict." }, - "423": { + "503": { "content": { "application/json": { "schema": { @@ -65148,40 +65984,202 @@ } } }, + "description": "Service unavailable." + } + } + } + }, + "/api/v1/health/": { + "get": { + "operationId": "api_v1_health_retrieve", + "summary": "Health check", + "tags": [ + "Health" + ], + "security": [ + { + "jwtAuth": [] + }, + {} + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Health" + } + } + }, "description": "" }, - "429": { + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "503": { + "description": "The recovery gate is closed: the database is unreachable or a restore is running." + } + } + } + }, + "/api/v1/health/readiness/": { + "get": { + "operationId": "api_v1_health_readiness_retrieve", + "summary": "Readiness check", + "tags": [ + "Health" + ], + "security": [ + { + "jwtAuth": [] + }, + {} + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/Readiness" } } }, "description": "" }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, "503": { + "description": "Not ready: host marker, database identity, migrations, fences or PII keys failed." + } + } + } + }, + "/api/v1/integrations/makerspace/{makerspace_id}/bounce-webhook": { + "post": { + "operationId": "api_v1_integrations_makerspace_bounce_webhook_create", + "description": "HMAC-SHA256 hex of ASCII timestamp + dot + raw JSON body. Five-minute replay window. Hard bounces/complaints only.", + "parameters": [ + { + "in": "header", + "name": "X-SpaceWorks-Signature", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "header", + "name": "X-SpaceWorks-Timestamp", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Notifications" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Bounce" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Bounce" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Bounce" + } + } + }, + "required": true + }, + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/BounceResult" } } }, "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." + }, + "503": { + "description": "A required dependency is unavailable." } } } }, - "/api/v1/public/{makerspace_slug}/my-bookings/": { + "/api/v1/integrations/makerspace/{makerspace_id}/delivery-logs": { "get": { - "operationId": "api_v1_public_my_bookings_retrieve", + "operationId": "api_v1_integrations_makerspace_delivery_logs_retrieve", "parameters": [ { - "in": "path", - "name": "makerspace_slug", + "in": "query", + "name": "channel", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "destination", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "event", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "feature", "schema": { "type": "string" + } + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" }, "required": true }, @@ -65194,21 +66192,14 @@ }, { "in": "query", - "name": "period", + "name": "status", "schema": { - "enum": [ - "upcoming", - "past" - ], - "type": "string", - "default": "upcoming", - "minLength": 1 - }, - "description": "* `upcoming` - upcoming\n* `past` - past" + "type": "string" + } } ], "tags": [ - "Member bookings" + "Notification delivery" ], "security": [ { @@ -65220,73 +66211,42 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberBookingList" + "$ref": "#/components/schemas/DeliveryPage" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Invalid request or bookings disabled." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Sign-in required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Active account required." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Makerspace or own booking not found." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Rate limit exceeded." + "description": "Not found, not visible to this actor, or the surface is not enabled here." } } } }, - "/api/v1/public/{makerspace_slug}/my-bookings/{public_token}/cancel/": { + "/api/v1/integrations/makerspace/{makerspace_id}/delivery-logs/{kind}/{id}/retry": { "post": { - "operationId": "api_v1_public_my_bookings_cancel_create", + "operationId": "api_v1_integrations_makerspace_delivery_logs_retry_create", "parameters": [ { "in": "path", - "name": "makerspace_slug", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "kind", "schema": { "type": "string" }, @@ -65294,37 +66254,35 @@ }, { "in": "path", - "name": "public_token", + "name": "makerspace_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Member bookings" + "Notification delivery" ], "requestBody": { "content": { - "type": { + "application/json": { "schema": { - "type": "object", - "additionalProperties": {}, - "description": "Unspecified request body" + "$ref": "#/components/schemas/Retry" } }, - "properties": { - "schema": {} + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Retry" + } }, - "additionalProperties": { + "multipart/form-data": { "schema": { - "type": "object", - "additionalProperties": {}, - "description": "Unspecified request body" + "$ref": "#/components/schemas/Retry" } } - } + }, + "required": true }, "security": [ { @@ -65336,140 +66294,67 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberBooking" + "$ref": "#/components/schemas/Delivery" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Invalid request or bookings disabled." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Sign-in required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Active account required." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Makerspace or own booking not found." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Rate limit exceeded." + "description": "Not found, not visible to this actor, or the surface is not enabled here." }, "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Tenant frozen for migration." + "description": "Makerspace writes are frozen for tenant migration." }, "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Tenant write gate unavailable." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Cancellation cutoff or state conflict." + "description": "A required dependency is unavailable." } } } }, - "/api/v1/public/{makerspace_slug}/my-bookings/{public_token}/reschedule/": { + "/api/v1/integrations/makerspace/{makerspace_id}/delivery-test": { "post": { - "operationId": "api_v1_public_my_bookings_reschedule_create", + "operationId": "api_v1_integrations_makerspace_delivery_test_create", "parameters": [ { "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "path", - "name": "public_token", + "name": "makerspace_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Member bookings" + "Notification delivery" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookingReschedule" + "$ref": "#/components/schemas/DeliveryTest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/BookingReschedule" + "$ref": "#/components/schemas/DeliveryTest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/BookingReschedule" + "$ref": "#/components/schemas/DeliveryTest" } } - }, - "required": true + } }, "security": [ { @@ -65481,111 +66366,48 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberBooking" + "$ref": "#/components/schemas/DeliveryTestResult" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Invalid request or bookings disabled." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Sign-in required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Active account required." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Makerspace or own booking not found." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Rate limit exceeded." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Cutoff, state or slot conflict." + "description": "Not found, not visible to this actor, or the surface is not enabled here." }, "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Tenant frozen." + "description": "Makerspace writes are frozen for tenant migration." }, "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Tenant gate unavailable." + "description": "A required dependency is unavailable." } } } }, - "/api/v1/public/{makerspace_slug}/my-bookings/calendar/": { + "/api/v1/integrations/makerspace/{makerspace_id}/email-suppressions": { "get": { - "operationId": "api_v1_public_my_bookings_calendar_retrieve", + "operationId": "api_v1_integrations_makerspace_email_suppressions_retrieve", "parameters": [ { "in": "path", - "name": "makerspace_slug", + "name": "makerspace_id", "schema": { - "type": "string" + "type": "integer" }, "required": true } ], "tags": [ - "Member bookings" + "Notification delivery" ], "security": [ { @@ -65597,15 +66419,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FeedState" + "$ref": "#/components/schemas/SuppressionPage" } } }, "description": "" }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." - }, "401": { "description": "Not signed in." }, @@ -65618,31 +66437,51 @@ } }, "post": { - "operationId": "api_v1_public_my_bookings_calendar_create", + "operationId": "api_v1_integrations_makerspace_email_suppressions_create", "parameters": [ { "in": "path", - "name": "makerspace_slug", + "name": "makerspace_id", "schema": { - "type": "string" + "type": "integer" }, "required": true } ], "tags": [ - "Member bookings" + "Notification delivery" ], - "security": [ - { - "jwtAuth": [] - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuppressionWrite" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/SuppressionWrite" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/SuppressionWrite" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FeedIssued" + "$ref": "#/components/schemas/Suppression" } } }, @@ -65667,21 +66506,31 @@ "description": "A required dependency is unavailable." } } - }, + } + }, + "/api/v1/integrations/makerspace/{makerspace_id}/email-suppressions/{id}": { "delete": { - "operationId": "api_v1_public_my_bookings_calendar_destroy", + "operationId": "api_v1_integrations_makerspace_email_suppressions_destroy", "parameters": [ { "in": "path", - "name": "makerspace_slug", + "name": "id", "schema": { - "type": "string" + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" }, "required": true } ], "tags": [ - "Member bookings" + "Notification delivery" ], "security": [ { @@ -65690,10 +66539,7 @@ ], "responses": { "204": { - "description": "No response body" - }, - "400": { - "description": "Validation failed, or the makerspace module this route belongs to is off." + "description": "Suppression removed after operator review." }, "401": { "description": "Not signed in." @@ -65713,106 +66559,86 @@ } } }, - "/api/v1/public/{makerspace_slug}/presence-sessions": { - "post": { - "operationId": "api_v1_public_presence_sessions_create", + "/api/v1/integrations/makerspace/{makerspace_id}/timing-policy": { + "get": { + "operationId": "api_v1_integrations_makerspace_timing_policy_retrieve", "parameters": [ { "in": "path", - "name": "makerspace_slug", + "name": "makerspace_id", "schema": { - "type": "string" + "type": "integer" }, "required": true } ], "tags": [ - "Presence" + "Notifications" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PresenceStart" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PresenceStart" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PresenceStart" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PresenceSession" + "$ref": "#/components/schemas/TimingPolicy" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid input." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "description": "Authentication required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Membership permission required." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "description": "Makerspace not found." - }, - "429": { - "description": "Rate limit exceeded." + "description": "Not found, not visible to this actor, or the surface is not enabled here." } } - } - }, - "/api/v1/public/{makerspace_slug}/presence-sessions/current": { - "get": { - "operationId": "api_v1_public_presence_sessions_current_retrieve", + }, + "patch": { + "operationId": "api_v1_integrations_makerspace_timing_policy_partial_update", "parameters": [ { "in": "path", - "name": "makerspace_slug", + "name": "makerspace_id", "schema": { - "type": "string" + "type": "integer" }, "required": true } ], "tags": [ - "Presence" + "Notifications" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedTimingPolicy" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedTimingPolicy" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedTimingPolicy" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -65823,299 +66649,234 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PresenceCurrent" + "$ref": "#/components/schemas/TimingPolicy" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid input." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "description": "Authentication required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Membership permission required." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "description": "Makerspace not found." + "description": "Not found, not visible to this actor, or the surface is not enabled here." }, - "429": { - "description": "Rate limit exceeded." + "423": { + "description": "Makerspace writes are frozen for tenant migration." + }, + "503": { + "description": "A required dependency is unavailable." } } } }, - "/api/v1/public/{makerspace_slug}/presence-sessions/current/end": { + "/api/v1/integrations/messaging/twilio/m/{makerspace_id}/inbound": { "post": { - "operationId": "api_v1_public_presence_sessions_current_end_create", + "operationId": "api_v1_integrations_messaging_twilio_m_inbound_create", + "description": "Twilio form callback. Requires X-Twilio-Signature, AccountSid and bound sender number.", "parameters": [ { - "in": "path", - "name": "makerspace_slug", + "in": "header", + "name": "X-Twilio-Signature", "schema": { "type": "string" }, "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true } ], "tags": [ - "Presence" + "Messaging webhooks" ], - "security": [ - { - "jwtAuth": [] + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } } - ], + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PresenceCurrent" + "type": "object", + "additionalProperties": {} } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { + }, + "text/xml": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "string" } } }, - "description": "Invalid input." + "description": "" }, - "401": { - "description": "Authentication required." + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Membership permission required." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "404": { - "description": "Makerspace not found." + "415": { + "description": "Unsupported media type for the request body." }, - "429": { - "description": "Rate limit exceeded." + "500": { + "description": "An internal or persistence failure prevented completing the request." } } } }, - "/api/v1/public/{makerspace_slug}/requests": { + "/api/v1/integrations/messaging/twilio/m/{makerspace_id}/status": { "post": { - "operationId": "api_v1_public_requests_create", - "summary": "Submit public borrow request", + "operationId": "api_v1_integrations_messaging_twilio_m_status_create", + "description": "Twilio form callback. Requires X-Twilio-Signature, AccountSid and bound sender number.", "parameters": [ { "in": "header", - "name": "Idempotency-Key", - "schema": { - "type": "string" - }, - "description": "Required for account-less submissions. Reusing a key with the same payload returns the original request; a different payload is rejected." - }, - { - "in": "header", - "name": "X-Nonce", - "schema": { - "type": "string" - }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." - }, - { - "in": "header", - "name": "X-Publishable-Key", + "name": "X-Twilio-Signature", "schema": { "type": "string" }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + "required": true }, { "in": "path", - "name": "makerspace_slug", + "name": "makerspace_id", "schema": { - "type": "string" + "type": "integer" }, "required": true } ], "tags": [ - "Public requests" + "Messaging webhooks" ], "requestBody": { "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RequestSubmit" - }, - "examples": { - "SubmitPublicEquipmentRequest": { - "value": { - "contact_name": "Shaan Shoukath", - "contact_email": "shaans@example.com", - "contact_phone": "+919876543210", - "requested_for": "Electronics workshop diagnostics", - "items": [ - { - "product_id": 42, - "quantity": 2 - } - ] - }, - "summary": "Submit public equipment request" - } - } - }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/RequestSubmit" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/RequestSubmit" + "type": "object", + "additionalProperties": {} } } - }, - "required": true + } }, - "security": [ - { - "jwtAuth": [] - }, - {} - ], "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RequestSubmitResponse" - } - } - }, - "description": "" - }, - "400": { + "200": { "content": { "application/json": { "schema": { "type": "object", - "additionalProperties": {}, - "properties": { - "feature": { - "type": "string" - } - } + "additionalProperties": {} } - } - }, - "description": "Invalid submission or loans feature disabled." - }, - "401": { - "content": { - "application/json": { + }, + "text/xml": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "string" } } }, - "description": "Authentication required." + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Not found." + "415": { + "description": "Unsupported media type for the request body." }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } + "500": { + "description": "An internal or persistence failure prevented completing the request." + } + } + } + }, + "/api/v1/integrations/messaging/twilio/platform/inbound": { + "post": { + "operationId": "api_v1_integrations_messaging_twilio_platform_inbound_create", + "description": "Twilio form callback. Requires X-Twilio-Signature, AccountSid and bound sender number.", + "parameters": [ + { + "in": "header", + "name": "X-Twilio-Signature", + "schema": { + "type": "string" }, - "description": "Workflow conflict." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "required": true + } + ], + "tags": [ + "Messaging webhooks" + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "additionalProperties": {} } - }, - "description": "Too many requests." - }, - "503": { + } + } + }, + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": {} } - } - }, - "description": "Service unavailable." - }, - "423": { - "content": { - "application/json": { + }, + "text/xml": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "string" } } }, - "description": "Tenant frozen for migration." + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "415": { + "description": "Unsupported media type for the request body." + }, + "500": { + "description": "An internal or persistence failure prevented completing the request." } } } }, - "/api/v1/public/{makerspace_slug}/spaces/": { - "get": { - "operationId": "api_v1_public_spaces_list", + "/api/v1/integrations/messaging/twilio/platform/status": { + "post": { + "operationId": "api_v1_integrations_messaging_twilio_platform_status_create", + "description": "Twilio form callback. Requires X-Twilio-Signature, AccountSid and bound sender number.", "parameters": [ { - "in": "path", - "name": "makerspace_slug", + "in": "header", + "name": "X-Twilio-Signature", "schema": { "type": "string" }, @@ -66123,155 +66884,119 @@ } ], "tags": [ - "Public bookings" + "Messaging webhooks" ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + } + }, "responses": { "200": { - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicBookableSpace" - } - } - } - }, - "description": "" - }, - "400": { "content": { "application/json": { "schema": { "type": "object", "additionalProperties": {} } - } - }, - "description": "Invalid request." - }, - "404": { - "content": { - "application/json": { + }, + "text/xml": { "schema": { - "type": "object", - "additionalProperties": {} + "type": "string" } } }, - "description": "Space not found." + "description": "" }, - "429": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Rate limit exceeded." + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "415": { + "description": "Unsupported media type for the request body." + }, + "500": { + "description": "An internal or persistence failure prevented completing the request." } } } }, - "/api/v1/public/{makerspace_slug}/spaces/{public_token}/availability/": { + "/api/v1/integrations/messaging/whatsapp/m/{makerspace_id}": { "get": { - "operationId": "api_v1_public_spaces_availability_retrieve", + "operationId": "api_v1_integrations_messaging_whatsapp_m_retrieve", "parameters": [ { "in": "query", - "name": "ends_at", + "name": "hub.challenge", "schema": { - "type": "string", - "format": "date-time" + "type": "string" }, "required": true }, { - "in": "path", - "name": "makerspace_slug", + "in": "query", + "name": "hub.mode", "schema": { "type": "string" }, "required": true }, { - "in": "path", - "name": "public_token", + "in": "query", + "name": "hub.verify_token", "schema": { - "type": "string", - "format": "uuid" + "type": "string" }, "required": true }, { - "in": "query", - "name": "starts_at", + "in": "path", + "name": "makerspace_id", "schema": { - "type": "string", - "format": "date-time" + "type": "integer" }, "required": true } ], "tags": [ - "Public bookings" + "Messaging webhooks" ], "responses": { "200": { "content": { - "application/json": { + "text/plain": { "schema": { - "$ref": "#/components/schemas/PublicSpaceAvailability" + "type": "string" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Invalid request." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, - "404": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Space not found." + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "429": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Rate limit exceeded." + "500": { + "description": "An internal or persistence failure prevented completing the request." } } - } - }, - "/api/v1/public/{makerspace_slug}/spaces/{public_token}/book/": { + }, "post": { - "operationId": "api_v1_public_spaces_book_create", + "operationId": "api_v1_integrations_messaging_whatsapp_m_create", + "description": "Meta messages webhook; X-Hub-Signature-256 covers the exact raw JSON body.", "parameters": [ { - "in": "path", - "name": "makerspace_slug", + "in": "header", + "name": "X-Hub-Signature-256", "schema": { "type": "string" }, @@ -66279,76 +67004,40 @@ }, { "in": "path", - "name": "public_token", + "name": "makerspace_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Public bookings" + "Messaging webhooks" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicBookingInput" + "type": "object", + "additionalProperties": {} } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PublicBookingInput" + "type": "object", + "additionalProperties": {} } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PublicBookingInput" + "type": "object", + "additionalProperties": {} } } - }, - "required": true - }, - "security": [ - { - "jwtAuth": [] } - ], + }, "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicBookingResponse" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Invalid request." - }, - "404": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Space not found." - }, - "429": { + "200": { "content": { "application/json": { "schema": { @@ -66357,140 +67046,84 @@ } } }, - "description": "Rate limit exceeded." + "description": "" }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Authentication is required." + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Active account and any existing membership waiver acceptance are required." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Booking conflict." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "423": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Tenant frozen for migration." + "415": { + "description": "Unsupported media type for the request body." }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Tenant write gate unavailable." + "500": { + "description": "An internal or persistence failure prevented completing the request." } } } }, - "/api/v1/public/{makerspace_slug}/stats/": { + "/api/v1/integrations/messaging/whatsapp/platform": { "get": { - "operationId": "api_v1_public_stats_retrieve", - "description": "Get public activity stats for a public makerspace.", - "summary": "Get public makerspace stats", + "operationId": "api_v1_integrations_messaging_whatsapp_platform_retrieve", "parameters": [ { - "in": "header", - "name": "X-Nonce", + "in": "query", + "name": "hub.challenge", "schema": { "type": "string" }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + "required": true }, { - "in": "header", - "name": "X-Publishable-Key", + "in": "query", + "name": "hub.mode", "schema": { "type": "string" }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + "required": true }, { - "in": "path", - "name": "makerspace_slug", + "in": "query", + "name": "hub.verify_token", "schema": { "type": "string" }, - "description": "Public makerspace code (for example TSEL) or slug.", "required": true } ], "tags": [ - "Public inventory" - ], - "security": [ - { - "jwtAuth": [] - }, - {} + "Messaging webhooks" ], "responses": { "200": { "content": { - "application/json": { + "text/plain": { "schema": { - "$ref": "#/components/schemas/PublicStats" + "type": "string" } } }, "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "500": { + "description": "An internal or persistence failure prevented completing the request." } } - } - }, - "/api/v1/public/{makerspace_slug}/tools/checklist": { + }, "post": { - "operationId": "api_v1_public_tools_checklist_create", - "summary": "Preview kit components for a scanned self-checkout tool", + "operationId": "api_v1_integrations_messaging_whatsapp_platform_create", + "description": "Meta messages webhook; X-Hub-Signature-256 covers the exact raw JSON body.", "parameters": [ { "in": "header", - "name": "X-Nonce", - "schema": { - "type": "string" - }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." - }, - { - "in": "header", - "name": "X-Publishable-Key", - "schema": { - "type": "string" - }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." - }, - { - "in": "path", - "name": "makerspace_slug", + "name": "X-Hub-Signature-256", "schema": { "type": "string" }, @@ -66498,158 +67131,78 @@ } ], "tags": [ - "Public requests" + "Messaging webhooks" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberKitPreviewInput" + "type": "object", + "additionalProperties": {} } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MemberKitPreviewInput" + "type": "object", + "additionalProperties": {} } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MemberKitPreviewInput" + "type": "object", + "additionalProperties": {} } } - }, - "required": true - }, - "security": [ - { - "jwtAuth": [] } - ], + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KitPreview" + "type": "object", + "additionalProperties": {} } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "415": { + "description": "Unsupported media type for the request body." }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "500": { + "description": "An internal or persistence failure prevented completing the request." } } } }, - "/api/v1/public/{makerspace_slug}/tools/checkout": { + "/api/v1/integrations/push/devices": { "post": { - "operationId": "api_v1_public_tools_checkout_create", - "summary": "Check out a public tool by QR", - "parameters": [ - { - "in": "header", - "name": "X-Nonce", - "schema": { - "type": "string" - }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." - }, - { - "in": "header", - "name": "X-Publishable-Key", - "schema": { - "type": "string" - }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." - }, - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" - }, - "required": true - } - ], + "operationId": "api_v1_integrations_push_devices_create", "tags": [ - "Public requests" + "Native push" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicToolCheckout" - }, - "examples": { - "PublicQRToolCheckout": { - "value": { - "payload": "BOX-ABC123", - "requester_name": "Shaan Shoukath", - "contact_email": "shaans@example.com", - "contact_phone": "+919876543210", - "evidence_id": 122, - "remark": "Borrowing for electronics workshop diagnostics." - }, - "summary": "Public QR tool checkout" - } + "$ref": "#/components/schemas/PushDeviceRegistration" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PublicToolCheckout" + "$ref": "#/components/schemas/PushDeviceRegistration" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PublicToolCheckout" + "$ref": "#/components/schemas/PushDeviceRegistration" } } }, @@ -66661,57 +67214,73 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicToolLoan" + "$ref": "#/components/schemas/PushDevice" } } }, "description": "" }, - "400": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PushDevice" } } }, - "description": "Invalid request." + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Authentication required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } + "409": { + "description": "Conflicts with the current state." + }, + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." + }, + "503": { + "description": "A required dependency is unavailable." + } + } + } + }, + "/api/v1/integrations/push/devices/{device_id}": { + "delete": { + "operationId": "api_v1_integrations_push_devices_destroy", + "parameters": [ + { + "in": "path", + "name": "device_id", + "schema": { + "type": "integer" }, - "description": "Not found." + "required": true + } + ], + "tags": [ + "Native push" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" }, - "409": { + "401": { "content": { "application/json": { "schema": { @@ -66719,9 +67288,9 @@ } } }, - "description": "Workflow conflict." + "description": "" }, - "429": { + "403": { "content": { "application/json": { "schema": { @@ -66729,9 +67298,9 @@ } } }, - "description": "Too many requests." + "description": "" }, - "503": { + "404": { "content": { "application/json": { "schema": { @@ -66739,59 +67308,33 @@ } } }, - "description": "Service unavailable." + "description": "" } } } }, - "/api/v1/public/{makerspace_slug}/tools/evidence-url": { + "/api/v1/integrations/telegram/test-alert": { "post": { - "operationId": "api_v1_public_tools_evidence_url_create", - "summary": "Create a public self-checkout evidence upload URL", - "parameters": [ - { - "in": "header", - "name": "X-Nonce", - "schema": { - "type": "string" - }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." - }, - { - "in": "header", - "name": "X-Publishable-Key", - "schema": { - "type": "string" - }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." - }, - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" - }, - "required": true - } - ], + "operationId": "api_v1_integrations_telegram_test_alert_create", + "summary": "Send Telegram test alert", "tags": [ - "Public requests" + "Telegram" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicToolEvidenceUrlRequest" + "$ref": "#/components/schemas/TelegramTestAlert" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PublicToolEvidenceUrlRequest" + "$ref": "#/components/schemas/TelegramTestAlert" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PublicToolEvidenceUrlRequest" + "$ref": "#/components/schemas/TelegramTestAlert" } } }, @@ -66803,148 +67346,169 @@ } ], "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EvidenceUrlResponse" - } - } - }, - "description": "" + "200": { + "description": "Delivery attempt result; `delivered: false` with a reason when Telegram is unconfigured or rejects the message." }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid request." + "description": "Validation failed, or the makerspace module this route belongs to is off." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Authentication required." + "description": "Not signed in." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "description": "Not found, not visible to this actor, or the surface is not enabled here." + } + } + } + }, + "/api/v1/integrations/telegram/webhook": { + "post": { + "operationId": "api_v1_integrations_telegram_webhook_create", + "description": "Retained so an already-registered webhook does not retry forever. Callback queries are acknowledged and discarded: accepting and rejecting borrow requests happens in the staff console, never from chat.", + "summary": "Acknowledge a Telegram webhook (no action is taken)", + "tags": [ + "Telegram" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TelegramWebhook" } }, - "description": "Workflow conflict." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/TelegramWebhook" } }, - "description": "Too many requests." + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/TelegramWebhook" + } + } + } + }, + "responses": { + "200": { + "description": "Acknowledged; no action taken." }, - "503": { + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "403": { + "description": "TELEGRAM_WEBHOOK_SECRET is unset, or the secret-token header is wrong." + }, + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." + } + } + } + }, + "/api/v1/internal/cron/return-reminders": { + "post": { + "operationId": "api_v1_internal_cron_return_reminders_create", + "tags": [ + "Health" + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReturnReminderCronResponse" } } }, - "description": "Service unavailable." + "description": "Return reminder run result." + }, + "403": { + "description": "Invalid cron secret." + }, + "404": { + "description": "Cron endpoint is not configured." } } } }, - "/api/v1/public/{makerspace_slug}/tools/return": { - "post": { - "operationId": "api_v1_public_tools_return_create", - "summary": "Return a public tool by QR", + "/api/v1/internal/tls-check": { + "get": { + "operationId": "api_v1_internal_tls_check_retrieve", + "summary": "Check whether on-demand TLS may be issued for a domain", "parameters": [ { - "in": "header", - "name": "X-Nonce", + "in": "query", + "name": "domain", "schema": { "type": "string" }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + "description": "Canonical hostname requested for on-demand TLS issuance.", + "required": true + } + ], + "tags": [ + "Internal" + ], + "responses": { + "200": { + "description": "TLS issuance is allowed." + }, + "403": { + "description": "TLS issuance is denied." + }, + "400": { + "description": "Managed mode: the Host header is not an infra host." }, + "503": { + "description": "The recovery gate is closed; deny issuance." + } + } + } + }, + "/api/v1/kiosk-stations/{public_token}/commands/online/": { + "post": { + "operationId": "api_v1_kiosk_stations_commands_online_create", + "summary": "Run one online kiosk command through the workflow", + "parameters": [ { "in": "header", - "name": "X-Publishable-Key", + "name": "X-Kiosk-Operator-Lease", "schema": { "type": "string" }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + "description": "Signed operator lease returned by the operator-lease route.", + "required": true }, { "in": "path", - "name": "makerspace_slug", + "name": "public_token", "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Public requests" + "Kiosk stations" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicToolScan" - }, - "examples": { - "PublicQRToolReturnScan": { - "value": { - "identifier": "shaans@example.com", - "payload": "BOX-ABC123", - "evidence_id": 123, - "remark": "Returned to the electronics shelf in good condition." - }, - "summary": "Public QR tool return scan" - } + "$ref": "#/components/schemas/KioskOnlineCommand" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PublicToolScan" + "$ref": "#/components/schemas/KioskOnlineCommand" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PublicToolScan" + "$ref": "#/components/schemas/KioskOnlineCommand" } } }, @@ -66960,7 +67524,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicToolLoan" + "$ref": "#/components/schemas/KioskOnlineCommandResult" } } }, @@ -66974,7 +67538,7 @@ } } }, - "description": "Invalid request." + "description": "Invalid payload or capability disabled." }, "401": { "content": { @@ -66984,7 +67548,7 @@ } } }, - "description": "Authentication required." + "description": "Operator is not signed in." }, "403": { "content": { @@ -66994,7 +67558,7 @@ } } }, - "description": "Permission denied." + "description": "Invalid Station session, lease or authority." }, "404": { "content": { @@ -67004,17 +67568,7 @@ } } }, - "description": "Not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Workflow conflict." + "description": "Target not found in this makerspace." }, "429": { "content": { @@ -67024,7 +67578,7 @@ } } }, - "description": "Too many requests." + "description": "Request rate limit exceeded." }, "503": { "content": { @@ -67034,15 +67588,25 @@ } } }, - "description": "Service unavailable." + "description": "Storage unavailable." } } } }, - "/api/v1/public/machine-service/3d-printer/requests/{public_token}/status": { - "get": { - "operationId": "api_v1_public_machine_service_3d_printer_requests_status_retrieve", + "/api/v1/kiosk-stations/{public_token}/evidence/prepare/": { + "post": { + "operationId": "api_v1_kiosk_stations_evidence_prepare_create", + "summary": "Presign a direct-to-staging evidence upload", "parameters": [ + { + "in": "header", + "name": "X-Kiosk-Operator-Lease", + "schema": { + "type": "string" + }, + "description": "Signed operator lease returned by the operator-lease route.", + "required": true + }, { "in": "path", "name": "public_token", @@ -67054,63 +67618,39 @@ } ], "tags": [ - "Public machine service" + "Kiosk stations" ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicPrinterStatus" - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KioskEvidencePrepare" } }, - "description": "" - } - } - } - }, - "/api/v1/public/makerspaces/": { - "get": { - "operationId": "api_v1_public_makerspaces_list", - "description": "List makerspaces that have a public site enabled.", - "summary": "List public makerspaces", - "parameters": [ - { - "in": "header", - "name": "X-Nonce", - "schema": { - "type": "string" + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/KioskEvidencePrepare" + } }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/KioskEvidencePrepare" + } + } }, - { - "in": "header", - "name": "X-Publishable-Key", - "schema": { - "type": "string" - }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." - } - ], - "tags": [ - "Public inventory" - ], + "required": true + }, "security": [ { "jwtAuth": [] - }, - {} + } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicMakerspace" - } + "$ref": "#/components/schemas/EvidenceUrlResponse" } } }, @@ -67120,149 +67660,230 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicInventoryError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Public inventory module or loans feature is disabled." + "description": "Invalid payload or capability disabled." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicInventoryError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid authentication credentials." + "description": "Operator is not signed in." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicInventoryError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Public API client is not authorized." + "description": "Invalid Station session, lease or authority." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicInventoryError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Item or public makerspace not found, or hidden by edition." + "description": "Target not found in this makerspace." }, "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicInventoryError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Public read rate limit exceeded." + "description": "Request rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Storage unavailable." } } } }, - "/api/v1/public/organizations/{slug}/": { - "get": { - "operationId": "api_v1_public_organizations_retrieve", - "summary": "Retrieve a public organization profile", + "/api/v1/kiosk-stations/{public_token}/operator-lease/": { + "post": { + "operationId": "api_v1_kiosk_stations_operator_lease_create", + "summary": "Issue a human operator lease on this Station session", "parameters": [ { "in": "path", - "name": "slug", + "name": "public_token", "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Public organizations" + "Kiosk stations" + ], + "security": [ + { + "jwtAuth": [] + } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicOrganization" + "$ref": "#/components/schemas/KioskOperatorLease" } } }, "description": "" }, - "404": { - "description": "Organization not found." + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid Station credential, session, origin, feature or lease." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid Station credential, session, origin, feature or lease." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid Station credential, session, origin, feature or lease." }, "429": { - "description": "Rate limit exceeded." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid Station credential, session, origin, feature or lease." } } - } - }, - "/api/v1/public/organizations/{slug}/events/": { - "get": { - "operationId": "api_v1_public_organizations_events_retrieve", - "summary": "List public events organized across makerspaces", + }, + "delete": { + "operationId": "api_v1_kiosk_stations_operator_lease_destroy", + "summary": "Revoke the caller's operator lease (operator sign-out)", "parameters": [ { - "in": "path", - "name": "slug", + "in": "header", + "name": "X-Kiosk-Operator-Lease", "schema": { "type": "string" }, + "description": "Signed operator lease returned by the operator-lease route.", + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, "required": true } ], "tags": [ - "Public organizations" + "Kiosk stations" + ], + "security": [ + { + "jwtAuth": [] + } ], "responses": { - "200": { + "204": { + "description": "No response body" + }, + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicOrganizationEventList" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" - }, - "404": { - "description": "Organization not found." + "description": "Invalid Station credential, session, origin, feature or lease." }, - "429": { - "description": "Rate limit exceeded." + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid Station credential, session, origin, feature or lease." } } } }, - "/api/v1/public/requests/{public_token}/status": { - "get": { - "operationId": "api_v1_public_requests_status_retrieve", - "summary": "Get request status by public token", + "/api/v1/kiosk-stations/{public_token}/replay/{client_command_id}/execute/": { + "post": { + "operationId": "api_v1_kiosk_stations_replay_execute_create", + "description": "Same Station cookie, origin/CSRF and human lease as prepare. Current authority and domain rules are checked again; duplicate success returns stable references.", + "summary": "Execute or recover one prepared handover command", "parameters": [ { "in": "header", - "name": "X-Nonce", + "name": "X-Kiosk-Operator-Lease", "schema": { "type": "string" }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + "description": "Signed operator lease returned by the operator-lease route.", + "required": true }, { "in": "header", - "name": "X-Publishable-Key", + "name": "X-Station-CSRF", "schema": { "type": "string" }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + "required": true + }, + { + "in": "path", + "name": "client_command_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true }, { "in": "path", @@ -67275,38 +67896,36 @@ } ], "tags": [ - "Public requests" + "Kiosk stations" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplayEnvelope" + } + } + }, + "required": true + }, + "security": [ + { + "KioskStationCookie": [] + }, + {} ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicRequestStatus" - }, - "examples": { - "PublicRequestStatus": { - "value": { - "public_token": "4f2b93e1-6ef4-41c2-8407-7f26bb3b2d8f", - "requested_for": "Electronics workshop diagnostics", - "status": "pending_approval", - "rejection_reason": "", - "created_at": "2026-06-11T10:30:00Z", - "items": [ - { - "product_name": "Soldering Iron", - "requested_quantity": 2 - } - ] - }, - "summary": "Public request status" - } + "$ref": "#/components/schemas/ReplayResult" } } }, "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { @@ -67314,7 +67933,7 @@ } } }, - "description": "Authentication required." + "description": "Invalid command, target, module or feature disabled." }, "403": { "content": { @@ -67324,9 +67943,9 @@ } } }, - "description": "Permission denied." + "description": "Station, origin, session, operator lease or live permission refused." }, - "404": { + "409": { "content": { "application/json": { "schema": { @@ -67334,9 +67953,9 @@ } } }, - "description": "Not found." + "description": "Ownership conflict, expired command, predecessor refusal or concurrent claim." }, - "429": { + "423": { "content": { "application/json": { "schema": { @@ -67344,63 +67963,72 @@ } } }, - "description": "Too many requests." - } - } - } - }, - "/api/v1/recovery": { - "get": { - "operationId": "api_v1_recovery_retrieve", - "summary": "Get deployment quarantine and residual-risk state", - "tags": [ - "Backup recovery" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "Tenant migration source gate is closed." + }, + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecoveryState" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" - }, - "401": { - "description": "Authentication is required." + "description": "Replay rate limit exceeded." }, - "403": { - "description": "The authenticated actor is not authorized." + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Storage or replay temporarily unavailable." } } - }, - "post": { - "operationId": "api_v1_recovery_create", - "summary": "Acknowledge residual risk and lift quarantine", + } + }, + "/api/v1/kiosk-stations/{public_token}/replay/prepare/": { + "post": { + "operationId": "api_v1_kiosk_stations_replay_prepare_create", + "description": "Requires the path-bound Station cookie, exact Origin/Referer, X-Station-CSRF and signed human lease; no browser login required. Send the identical envelope to execute.", + "summary": "Prepare an ordered command and staging evidence upload", + "parameters": [ + { + "in": "header", + "name": "X-Kiosk-Operator-Lease", + "schema": { + "type": "string" + }, + "description": "Signed operator lease returned by the operator-lease route.", + "required": true + }, + { + "in": "header", + "name": "X-Station-CSRF", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], "tags": [ - "Backup recovery" + "Kiosk stations" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecoveryAcknowledge" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/RecoveryAcknowledge" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/RecoveryAcknowledge" + "$ref": "#/components/schemas/ReplayEnvelope" } } }, @@ -67408,305 +68036,21286 @@ }, "security": [ { - "jwtAuth": [] - } + "KioskStationCookie": [] + }, + {} ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecoveryState" + "$ref": "#/components/schemas/ReplayResult" } } }, "description": "" }, "400": { - "description": "The request is invalid for the current lifecycle state." - }, - "401": { - "description": "Authentication is required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid command, target, module or feature disabled." }, "403": { - "description": "The authenticated actor is not authorized." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Station, origin, session, operator lease or live permission refused." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Ownership conflict, expired command, predecessor refusal or concurrent claim." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Tenant migration source gate is closed." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Replay rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Storage or replay temporarily unavailable." } } } }, - "/api/v1/webhooks/razorpay/{public_code}": { + "/api/v1/kiosk-stations/{public_token}/session/": { "post": { - "operationId": "api_v1_webhooks_razorpay_create", - "description": "Verifies the X-Razorpay-Signature HMAC over request.body using the addressed makerspace's webhook secret before applying an idempotent event.", - "summary": "Receive a makerspace Razorpay webhook", + "operationId": "api_v1_kiosk_stations_session_create", + "summary": "Exchange a kiosk Station PIN for a Station session", "parameters": [ { "in": "path", - "name": "public_code", + "name": "public_token", "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Payments" + "Kiosk stations" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KioskStationPin" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/KioskStationPin" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/KioskStationPin" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookResponse" + "$ref": "#/components/schemas/KioskStationBootstrap" } } }, - "description": "Verified event acknowledged (acted on or deliberately ignored)." + "description": "" }, "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookResponse" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid signature, payload, or configuration." + "description": "Invalid Station credential, session, origin, feature or lease." }, - "404": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookResponse" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace was not found." + "description": "Invalid Station credential, session, origin, feature or lease." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid Station credential, session, origin, feature or lease." } } - } - }, - "/api/v1/webhooks/stripe/{public_code}": { - "post": { - "operationId": "api_v1_webhooks_stripe_create", - "description": "Verifies the Stripe signature using request.body and the addressed makerspace's webhook secret before applying an idempotent event.", - "summary": "Receive a makerspace Stripe webhook", + }, + "delete": { + "operationId": "api_v1_kiosk_stations_session_destroy", + "summary": "End the Station session and revoke its operator leases", "parameters": [ { "in": "path", - "name": "public_code", + "name": "public_token", "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Payments" + "Kiosk stations" + ], + "security": [ + { + "KioskStationCookie": [] + } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WebhookResponse" - } - } - }, - "description": "Verified event acknowledged (acted on or deliberately ignored)." + "204": { + "description": "No response body" }, - "400": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookResponse" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid signature, payload, or configuration." + "description": "Invalid Station credential, session, origin, feature or lease." }, - "404": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookResponse" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace was not found." + "description": "Invalid Station credential, session, origin, feature or lease." } } } }, - "/api/v1/webhooks/stripe/connect": { + "/api/v1/kiosk-stations/{public_token}/work-package/": { "post": { - "operationId": "api_v1_webhooks_stripe_connect_create", - "summary": "Receive a platform Stripe Connect webhook", + "operationId": "api_v1_kiosk_stations_work_package_create", + "summary": "Load the minimal work package for one handover", + "parameters": [ + { + "in": "header", + "name": "X-Kiosk-Operator-Lease", + "schema": { + "type": "string" + }, + "description": "Signed operator lease returned by the operator-lease route.", + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], "tags": [ - "Payments" + "Kiosk stations" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KioskWorkPackageRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/KioskWorkPackageRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/KioskWorkPackageRequest" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookResponse" + "$ref": "#/components/schemas/KioskWorkPackage" } } }, - "description": "Verified event acknowledged (acted on or deliberately ignored)." + "description": "" }, "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookResponse" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid signature, payload, or configuration." + "description": "Invalid payload or capability disabled." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Operator is not signed in." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid Station session, lease or authority." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookResponse" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace was not found." + "description": "Target not found in this makerspace." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Request rate limit exceeded." } } } - } - }, - "components": { - "schemas": { - "AcceptQuantity": { - "type": "object", - "properties": { - "item_id": { - "type": "integer" - }, - "quantity": { - "type": "integer", - "minimum": 0 + }, + "/api/v1/live/": { + "get": { + "operationId": "api_v1_live_retrieve", + "description": "text/event-stream of `change` events `{kind, makerspace_id, target_type, target_id, actor_id, ts}` for every makerspace the session may see. Clients invalidate cached queries on an event and refetch; no tenant content travels on the stream. 503 when the deployment has no Redis.", + "summary": "Live update stream (Server-Sent Events)", + "tags": [ + "Health" + ], + "security": [ + { + "jwtAuth": [] } - }, - "required": [ - "item_id", - "quantity" - ] - }, - "AcceptRequest": { - "type": "object", - "properties": { - "accepted_quantities": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AcceptQuantity" - } + ], + "responses": { + "200": { + "content": { + "text/event-stream": { + "schema": { + "type": "string" + } + } + }, + "description": "" + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Account access is restricted." + }, + "503": { + "description": "No usable Redis (unset, misconfigured or unreachable); poll instead." } } - }, - "AccessLevelEnum": { - "enum": [ - "operate", - "manage", - "full" + } + }, + "/api/v1/me/messaging": { + "get": { + "operationId": "api_v1_me_messaging_retrieve", + "parameters": [ + { + "in": "query", + "name": "makerspace", + "schema": { + "type": "integer" + }, + "required": true + } ], - "type": "string", - "description": "* `operate` - Operate\n* `manage` - Manage\n* `full` - Full" - }, - "AccessStatusEnum": { - "enum": [ - "active", - "restricted", - "suspended" + "tags": [ + "Messaging" ], - "type": "string", - "description": "* `active` - Active\n* `restricted` - Restricted\n* `suspended` - Suspended" - }, - "AccountFailure": { - "type": "object", - "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" + "security": [ + { + "jwtAuth": [] } - }, - "required": [ - "detail" - ] - }, - "AccountProfile": { - "type": "object", - "properties": { - "display_name": { - "type": "string", - "maxLength": 200 + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberMessaging" + } + } + }, + "description": "" }, - "first_name": { - "type": "string", - "maxLength": 150 + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." }, - "last_name": { - "type": "string", - "maxLength": 150 + "401": { + "description": "Not signed in." }, - "phone": { - "type": "string", - "maxLength": 32 + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." } } - }, - "AccountSession": { - "type": "object", - "properties": { - "id": { - "type": "string" + } + }, + "/api/v1/me/messaging/consent": { + "post": { + "operationId": "api_v1_me_messaging_consent_create", + "tags": [ + "Messaging" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConsentWrite" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ConsentWrite" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ConsentWrite" + } + } }, - "kind": { - "$ref": "#/components/schemas/AccountSessionKindEnum" + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberMessaging" + } + } + }, + "description": "" }, - "created_at": { - "type": "string", - "format": "date-time" + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." }, - "expires_at": { - "type": "string", - "format": "date-time", - "nullable": true + "401": { + "description": "Not signed in." }, - "last_used_at": { - "type": "string", - "format": "date-time", + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "409": { + "description": "Conflicts with the current state." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." + }, + "503": { + "description": "A required dependency is unavailable." + } + } + } + }, + "/api/v1/member/archived-payments": { + "get": { + "operationId": "api_v1_member_archived_payments_list", + "description": "Lists archived makerspaces where the caller retains an active membership and has payment history available to read or settle.", + "summary": "Discover the caller's archived makerspace payments", + "tags": [ + "Payments" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ArchivedPaymentSummary" + } + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/account/cancel": { + "post": { + "operationId": "api_v1_member_makerspaces_account_cancel_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member account" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipReason" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MembershipReason" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MembershipReason" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipAccount" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + } + }, + "description": "Disabled module/feature, invalid input, or unavailable export." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/account/exports": { + "get": { + "operationId": "api_v1_member_makerspaces_account_exports_retrieve", + "summary": "List the caller's personal exports", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member account" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjectExportList" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + } + }, + "description": "Disabled module/feature, invalid input, or unavailable export." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + } + } + }, + "post": { + "operationId": "api_v1_member_makerspaces_account_exports_create", + "summary": "Request a personal ZIP/JSON data export", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member account" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjectExport" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + } + }, + "description": "Disabled module/feature, invalid input, or unavailable export." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/account/exports/{job_id}": { + "get": { + "operationId": "api_v1_member_makerspaces_account_exports_retrieve_2", + "summary": "Poll the caller's personal export", + "parameters": [ + { + "in": "path", + "name": "job_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member account" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjectExport" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + } + }, + "description": "Disabled module/feature, invalid input, or unavailable export." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/account/exports/{job_id}/download": { + "post": { + "operationId": "api_v1_member_makerspaces_account_exports_download_create", + "summary": "Sign a short-lived personal export download URL", + "parameters": [ + { + "in": "path", + "name": "job_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member account" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DataExportDownloadUrl" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + } + }, + "description": "Disabled module/feature, invalid input, or unavailable export." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/account/leave": { + "post": { + "operationId": "api_v1_member_makerspaces_account_leave_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member account" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipReason" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MembershipReason" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MembershipReason" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipAccount" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + } + }, + "description": "Disabled module/feature, invalid input, or unavailable export." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/account/membership": { + "get": { + "operationId": "api_v1_member_makerspaces_account_membership_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member account" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipAccount" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + } + }, + "description": "Disabled module/feature, invalid input, or unavailable export." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/account/profile": { + "get": { + "operationId": "api_v1_member_makerspaces_account_profile_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member account" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountProfile" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + } + }, + "description": "Disabled module/feature, invalid input, or unavailable export." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + } + } + }, + "patch": { + "operationId": "api_v1_member_makerspaces_account_profile_partial_update", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member account" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedAccountProfile" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedAccountProfile" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedAccountProfile" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountProfile" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + } + }, + "description": "Disabled module/feature, invalid input, or unavailable export." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/account/sessions": { + "get": { + "operationId": "api_v1_member_makerspaces_account_sessions_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member account" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountSessionPage" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + } + }, + "description": "Disabled module/feature, invalid input, or unavailable export." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/account/sessions/{session_id}": { + "delete": { + "operationId": "api_v1_member_makerspaces_account_sessions_destroy", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "session_id", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Member account" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + } + }, + "description": "Disabled module/feature, invalid input, or unavailable export." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFailure" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/activity": { + "get": { + "operationId": "api_v1_member_makerspaces_activity_retrieve", + "summary": "Retrieve the caller's makerspace activity", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member activity" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberActivity" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Membership module disabled." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "An active membership is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Makerspace not found." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/admission": { + "get": { + "operationId": "api_v1_member_makerspaces_admission_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdmissionRequest" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/admission/{request_id}/cancel": { + "post": { + "operationId": "api_v1_member_makerspaces_admission_cancel_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "request_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdmissionRequest" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/admission/{request_id}/vouch": { + "post": { + "operationId": "api_v1_member_makerspaces_admission_vouch_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "request_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdmissionVouchOutcome" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/admission/export": { + "get": { + "operationId": "api_v1_member_makerspaces_admission_export_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdmissionExport" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/certifications": { + "get": { + "operationId": "api_v1_member_makerspaces_certifications_retrieve", + "summary": "List your own certification history", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + }, + "description": "Page number (24 rows per page)." + } + ], + "tags": [ + "Member certifications" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberCertificationPage" + } + } + }, + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/collaborative-events/": { + "get": { + "operationId": "api_v1_member_makerspaces_collaborative_events_list", + "summary": "List events hosted by accepted collaborators", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member events" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CollaborativeEvent" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid event request." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Active membership is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Collaborative event not found." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/collaborative-events/{id}/register/": { + "post": { + "operationId": "api_v1_member_makerspaces_collaborative_events_register_create", + "description": "Invite-only admission can resolve a matching verified account email when the code is omitted. Partner eligibility, host waiver and normal admission rules still apply.", + "summary": "Register for a collaborative event", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member events" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollaborativeEventRegistrationInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/CollaborativeEventRegistrationInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/CollaborativeEventRegistrationInput" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicEventRegistrationResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid event request." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Active membership is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Collaborative event not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/directory": { + "get": { + "operationId": "api_v1_member_makerspaces_directory_retrieve", + "summary": "List members who published a profile", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Match on username, display name, headline or institution. Contact details are never searched." + } + ], + "tags": [ + "Member profile" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Directory" + } + } + }, + "description": "" + }, + "401": { + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "An active membership is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/directory/{membership_id}": { + "get": { + "operationId": "api_v1_member_makerspaces_directory_retrieve_2", + "summary": "Retrieve another member's published profile", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "membership_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member profile" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileRead" + } + } + }, + "description": "" + }, + "401": { + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "An active membership is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/event-calendar-feed/": { + "get": { + "operationId": "api_v1_member_makerspaces_event_calendar_feed_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member events" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberCalendarFeedState" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + }, + "post": { + "operationId": "api_v1_member_makerspaces_event_calendar_feed_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member events" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberCalendarFeedIssue" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MemberCalendarFeedIssue" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MemberCalendarFeedIssue" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberCalendarFeedIssued" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Concurrent rotation conflict." + } + } + }, + "delete": { + "operationId": "api_v1_member_makerspaces_event_calendar_feed_destroy", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member events" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/event-certificates/{id}/download/": { + "get": { + "operationId": "api_v1_member_makerspaces_event_certificates_download_retrieve", + "description": "Private audited certificate download; configurable renders are bounded to 30/hour per account.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member events" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificateDownload" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + }, + "410": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Certificate revoked." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Certificate storage unavailable." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid feedback answers." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Active membership is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Feedback resource not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Feedback or certificate state conflict." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/event-registrations/{id}/calendar.ics": { + "get": { + "operationId": "api_v1_member_makerspaces_event_registrations_calendar.ics_retrieve_2", + "description": "Exact row ownership and historical admission audience authorize this read; current organization eligibility is not required. No access to other member history.", + "summary": "Download one owned registration calendar", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member events" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "text/calendar": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/event-registrations/{id}/cancel/": { + "post": { + "operationId": "api_v1_member_makerspaces_event_registrations_cancel_create", + "description": "Releases pending charges and uses the event's existing waitlist promotion policy. Settled charges are retained; this action does not issue a refund. Members may cancel only before the event's cancellation cutoff (null means start).", + "summary": "Cancel the caller's event registration", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member events" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberRegistrationCancelResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid body or events module disabled (field-keyed errors)." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "An active account owning an account/invite registration, or an active membership, is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Owned registration not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Registration cannot be cancelled." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Tenant frozen for migration." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Tenant write gate unavailable." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/event-registrations/{id}/feedback/": { + "get": { + "operationId": "api_v1_member_makerspaces_event_registrations_feedback_retrieve", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member events" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackForm" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid feedback answers." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Active membership is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Feedback resource not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Feedback or certificate state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + } + } + }, + "post": { + "operationId": "api_v1_member_makerspaces_event_registrations_feedback_create", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member events" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackSubmission" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/FeedbackSubmission" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/FeedbackSubmission" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackSubmissionResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid feedback answers." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Active membership is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Feedback resource not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Feedback or certificate state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/event-registrations/{id}/qr": { + "get": { + "operationId": "api_v1_member_makerspaces_event_registrations_qr_retrieve", + "summary": "Render the caller's event check-in QR code", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member activity" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "image/svg+xml": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "Check-in QR code as SVG." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Active membership or an owned account/invite registration is required." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Registration not found." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/event-registrations/{id}/ticket/": { + "get": { + "operationId": "api_v1_member_makerspaces_event_registrations_ticket_retrieve", + "summary": "Read the caller's event ticket", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member activity" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberEventTicket" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/event-registrations/{id}/ticket.png": { + "get": { + "operationId": "api_v1_member_makerspaces_event_registrations_ticket.png_retrieve", + "summary": "Download the caller's registered or attended ticket", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member activity" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/event-registrations/calendar.ics": { + "get": { + "operationId": "api_v1_member_makerspaces_event_registrations_calendar.ics_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member events" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "text/calendar": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "RFC 5545 calendar (`text/calendar; charset=utf-8`)." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Active membership required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Makerspace not found." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Events module unavailable." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/loans/{id}/renew": { + "post": { + "operationId": "api_v1_member_makerspaces_loans_renew_create", + "summary": "Extend an issued loan within its policy", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Loans" + ], + "requestBody": { + "content": { + "type": { + "schema": { + "type": "object", + "additionalProperties": {}, + "description": "Unspecified request body" + } + }, + "properties": { + "schema": {} + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoanRenewal" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/member-card": { + "get": { + "operationId": "api_v1_member_makerspaces_member_card_retrieve", + "summary": "Read my member card", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member profile" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OwnMemberCard" + } + } + }, + "description": "" + }, + "400": { + "description": "Membership module disabled or invalid input." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "description": "An active membership is required." + }, + "404": { + "description": "No card has been issued to this membership." + } + } + }, + "patch": { + "operationId": "api_v1_member_makerspaces_member_card_partial_update", + "summary": "Set the name printed on my card", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member profile" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedMemberCardOwnUpdate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedMemberCardOwnUpdate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedMemberCardOwnUpdate" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberCard" + } + } + }, + "description": "" + }, + "400": { + "description": "Membership module disabled or invalid input." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "description": "An active membership is required." + }, + "404": { + "description": "No card has been issued to this membership." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/member-card/photo": { + "post": { + "operationId": "api_v1_member_makerspaces_member_card_photo_create", + "summary": "Create a card photo upload URL (staging only)", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member profile" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberCardPhotoPresign" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MemberCardPhotoPresign" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MemberCardPhotoPresign" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "" + }, + "400": { + "description": "Membership module disabled or invalid input." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "description": "An active membership is required." + }, + "404": { + "description": "No card has been issued to this membership." + }, + "503": { + "description": "Storage unavailable." + } + } + }, + "put": { + "operationId": "api_v1_member_makerspaces_member_card_photo_update", + "summary": "Attach an uploaded card photo (requires consent)", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member profile" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberCardPhotoFinalize" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MemberCardPhotoFinalize" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MemberCardPhotoFinalize" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberCard" + } + } + }, + "description": "" + }, + "400": { + "description": "Membership module disabled or invalid input." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "description": "An active membership is required." + }, + "404": { + "description": "No card has been issued to this membership." + } + } + }, + "delete": { + "operationId": "api_v1_member_makerspaces_member_card_photo_destroy", + "summary": "Remove my card photo", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member profile" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberCard" + } + } + }, + "description": "" + }, + "400": { + "description": "Membership module disabled or invalid input." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "description": "An active membership is required." + }, + "404": { + "description": "No card has been issued to this membership." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/member-card/preview.pdf": { + "get": { + "operationId": "api_v1_member_makerspaces_member_card_preview.pdf_retrieve", + "summary": "Preview my card as a watermarked PDF", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member profile" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "" + }, + "400": { + "description": "Membership module disabled or invalid input." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "description": "An active membership is required." + }, + "404": { + "description": "No card has been issued to this membership." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/my-usage": { + "get": { + "operationId": "api_v1_member_makerspaces_my_usage_retrieve", + "description": "Non-payment usage requires an active membership and the membership module, like member activity. Otherwise eligible callers receive payments only. Payment totals follow member_may_see_own_charges and remain visible with membership and payment rail modules disabled. Revoked memberships are refused. All data is filtered by the authenticated user; no member id is accepted. Loan counts represent handovers, deduplicating direct loans' linked workflow requests; monthly returns count ReturnEvents by event time, including partial returns. Legacy direct returns without events use returned_at; quantities are not inferred. Machine hours are attributed MachineUsageEntry hours only; unknown historical attribution is excluded. Booking counts exclude cancelled reservations.", + "summary": "Retrieve only the caller's personal usage charts", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member activity" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberUsage" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "No active membership or own charge visibility." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Makerspace not found or not servable." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/payment-documents": { + "get": { + "operationId": "api_v1_member_makerspaces_payment_documents_retrieve", + "summary": "List scoped payment documents", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + } + ], + "tags": [ + "Payments" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentPage" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/payment-documents/{document_id}/download-url": { + "get": { + "operationId": "api_v1_member_makerspaces_payment_documents_download_url_retrieve", + "summary": "Get a user-bound five-minute PDF download URL", + "parameters": [ + { + "in": "path", + "name": "document_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Payments" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DownloadGrant" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/payments": { + "get": { + "operationId": "api_v1_member_makerspaces_payments_list", + "description": "Active memberships and account-only charge owners may read their own history. A revoked member reapplying through an approved admission may read only that application's pending admission charge; previous membership debt stays refused.", + "summary": "List the caller's payment history", + "parameters": [ + { + "in": "query", + "name": "due_state", + "schema": { + "type": "string", + "enum": [ + "due_today", + "overdue", + "upcoming" + ] + } + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Payments" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberPayment" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentFilterError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "No active membership and no charge here." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/payments/{payment_id}/checkout": { + "post": { + "operationId": "api_v1_member_makerspaces_payments_checkout_create", + "summary": "Generate a Checkout link for the caller's pending payment", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "payment_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Payments" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckoutUrl" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "No pending charge of yours here." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "`payment_rail_conflict`: the charge uses the native rail." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "`payments_unavailable`: no live online rail for this charge." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/payments/{payment_id}/mobile-intent": { + "post": { + "operationId": "api_v1_member_makerspaces_payments_mobile_intent_create", + "description": "Requires an attested device token and ownership of a pending charge in the selected makerspace. Returns vendor=stripe PaymentSheet parameters or vendor=razorpay native Order parameters. An existing hosted checkout returns payment_rail_conflict (409); unavailable payments return 503.", + "summary": "Create or retrieve a native mobile payment intent", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "payment_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Payments" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MobilePaymentIntentResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/profile": { + "get": { + "operationId": "api_v1_member_makerspaces_profile_retrieve", + "summary": "Retrieve the caller's own profile", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member profile" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileRead" + } + } + }, + "description": "" + }, + "401": { + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "An active membership is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + } + } + }, + "put": { + "operationId": "api_v1_member_makerspaces_profile_update", + "summary": "Update the caller's own profile", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member profile" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileWrite" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ProfileWrite" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ProfileWrite" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileRead" + } + } + }, + "description": "" + }, + "401": { + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "An active membership is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/profile/image": { + "post": { + "operationId": "api_v1_member_makerspaces_profile_image_create", + "summary": "Create a profile image upload URL", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member profile" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileImageUploadRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ProfileImageUploadRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ProfileImageUploadRequest" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadResponse" + } + } + }, + "description": "" + }, + "400": { + "description": "Invalid image upload request." + }, + "403": { + "description": "An active membership is required." + }, + "429": { + "description": "Too many image upload requests." + }, + "503": { + "description": "Public image storage is unavailable." + } + } + }, + "put": { + "operationId": "api_v1_member_makerspaces_profile_image_update", + "summary": "Attach an uploaded image to the profile or one of its projects", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member profile" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileImageAttachRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ProfileImageAttachRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ProfileImageAttachRequest" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileRead" + } + } + }, + "description": "" + }, + "400": { + "description": "Invalid image object key or size." + }, + "403": { + "description": "An active membership is required." + }, + "503": { + "description": "Public image storage is unavailable." + } + } + }, + "delete": { + "operationId": "api_v1_member_makerspaces_profile_image_destroy", + "summary": "Clear a profile or project image", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member profile" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileRead" + } + } + }, + "description": "" + }, + "400": { + "description": "Unknown project." + }, + "403": { + "description": "An active membership is required." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/referrals": { + "post": { + "operationId": "api_v1_member_makerspaces_referrals_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Memberships" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReferralCreate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ReferralCreate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ReferralCreate" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReferralOutcome" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/verification": { + "post": { + "operationId": "api_v1_member_makerspaces_verification_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Memberships" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberVerificationRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MemberVerificationRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MemberVerificationRequest" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberVerificationResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/waiver": { + "get": { + "operationId": "api_v1_member_makerspaces_waiver_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberWaiverResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/waiver/accept": { + "post": { + "operationId": "api_v1_member_makerspaces_waiver_accept_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WaiverAcceptResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/memberships/{id}/accept-invitation": { + "post": { + "operationId": "api_v1_memberships_accept_invitation_create", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Memberships" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdmissionAnswers" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/AdmissionAnswers" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/AdmissionAnswers" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvitationClaimOutcome" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/memberships/invitations": { + "get": { + "operationId": "api_v1_memberships_invitations_retrieve", + "tags": [ + "Memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvitationList" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/memberships/invitations/{id}/claim": { + "post": { + "operationId": "api_v1_memberships_invitations_claim_create", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Memberships" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdmissionAnswers" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/AdmissionAnswers" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/AdmissionAnswers" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvitationClaimOutcome" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/memberships/me": { + "get": { + "operationId": "api_v1_memberships_me_retrieve", + "tags": [ + "Memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MyMemberships" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/metrics/": { + "get": { + "operationId": "api_v1_metrics_retrieve", + "description": "Prometheus text exposition of queue depth, request states, notification delivery outcomes, storage accounting and evidence retention. Requires the deployment's METRICS_TOKEN as a bearer token; 404 when no token is configured.", + "summary": "Prometheus metrics", + "tags": [ + "Health" + ], + "security": [ + {} + ], + "responses": { + "200": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "" + }, + "401": { + "description": "Missing or wrong METRICS_TOKEN." + }, + "404": { + "description": "METRICS_TOKEN is not set." + } + } + } + }, + "/api/v1/notifications/announcements/{makerspace_id}": { + "get": { + "operationId": "api_v1_notifications_announcements_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + } + ], + "tags": [ + "Member communication" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InboxPage" + } + } + }, + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + } + } + } + }, + "/api/v1/notifications/broadcasts/{makerspace_id}": { + "get": { + "operationId": "api_v1_notifications_broadcasts_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member communication" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BroadcastOptions" + } + } + }, + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + } + } + }, + "post": { + "operationId": "api_v1_notifications_broadcasts_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member communication" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BroadcastInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/BroadcastInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/BroadcastInput" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BroadcastResult" + } + } + }, + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." + }, + "503": { + "description": "A required dependency is unavailable." + } + } + } + }, + "/api/v1/notifications/inbox/{makerspace_id}": { + "get": { + "operationId": "api_v1_notifications_inbox_retrieve", + "parameters": [ + { + "in": "query", + "name": "feature", + "schema": { + "type": "string", + "enum": [ + "api_access", + "bookings", + "certifications", + "devices", + "events", + "hardware_requests", + "maintenance", + "members", + "payments", + "printing", + "stock" + ] + } + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "unread", + "schema": { + "type": "boolean" + } + } + ], + "tags": [ + "Personal notifications" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InboxPage" + } + } + }, + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + } + } + } + }, + "/api/v1/notifications/inbox/{makerspace_id}/{id}/read": { + "post": { + "operationId": "api_v1_notifications_inbox_read_create", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Personal notifications" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Inbox" + } + } + }, + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." + }, + "503": { + "description": "A required dependency is unavailable." + } + } + } + }, + "/api/v1/notifications/inbox/{makerspace_id}/read-all": { + "post": { + "operationId": "api_v1_notifications_inbox_read_all_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Personal notifications" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadAll" + } + } + }, + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." + }, + "503": { + "description": "A required dependency is unavailable." + } + } + } + }, + "/api/v1/notifications/inbox/{makerspace_id}/unread-count": { + "get": { + "operationId": "api_v1_notifications_inbox_unread_count_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Personal notifications" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Count" + } + } + }, + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + } + } + } + }, + "/api/v1/notifications/makerspace/{makerspace_id}": { + "get": { + "operationId": "api_v1_notifications_makerspace_list", + "summary": "List makerspace notifications", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "unread", + "schema": { + "type": "boolean" + }, + "description": "When true, return only unread notifications." + } + ], + "tags": [ + "Notifications" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedNotificationList" + } + } + }, + "description": "" + }, + "400": { + "description": "Notifications module is disabled." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/notifications/makerspace/{makerspace_id}/{id}/read": { + "post": { + "operationId": "api_v1_notifications_makerspace_read_create", + "summary": "Mark a makerspace notification read", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Notifications" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Notification" + } + } + }, + "description": "" + }, + "400": { + "description": "Notifications module is disabled." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/notifications/makerspace/{makerspace_id}/read-all": { + "post": { + "operationId": "api_v1_notifications_makerspace_read_all_create", + "summary": "Mark all makerspace notifications read", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Notifications" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationMarkAllRead" + } + } + }, + "description": "" + }, + "400": { + "description": "Notifications module is disabled." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/notifications/makerspace/{makerspace_id}/unread-count": { + "get": { + "operationId": "api_v1_notifications_makerspace_unread_count_retrieve", + "summary": "Get unread makerspace notification count", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Notifications" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationUnreadCount" + } + } + }, + "description": "" + }, + "400": { + "description": "Notifications module is disabled." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/notifications/preferences/{makerspace_id}": { + "get": { + "operationId": "api_v1_notifications_preferences_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Personal notifications" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferenceResponse" + } + } + }, + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + } + } + }, + "patch": { + "operationId": "api_v1_notifications_preferences_partial_update", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Personal notifications" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedPreferenceWrite" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedPreferenceWrite" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedPreferenceWrite" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferenceResponse" + } + } + }, + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." + }, + "503": { + "description": "A required dependency is unavailable." + } + } + } + }, + "/api/v1/notifications/unsubscribe/{token}": { + "post": { + "operationId": "api_v1_notifications_unsubscribe_create", + "parameters": [ + { + "in": "path", + "name": "token", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Personal notifications" + ], + "requestBody": { + "content": { + "type": { + "schema": { + "type": "object", + "additionalProperties": {}, + "description": "Unspecified request body" + } + }, + "required": { + "schema": { + "type": "object", + "additionalProperties": {}, + "description": "Unspecified request body" + } + }, + "properties": { + "schema": { + "List-Unsubscribe": { + "type": "string", + "enum": [ + "One-Click" + ] + } + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnsubscribeResult" + } + } + }, + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "415": { + "description": "Unsupported media type for the request body." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." + }, + "503": { + "description": "A required dependency is unavailable." + } + } + } + }, + "/api/v1/payment-documents/{document_id}/download": { + "get": { + "operationId": "api_v1_payment_documents_download_retrieve", + "summary": "Download an issued payment document PDF", + "parameters": [ + { + "in": "path", + "name": "document_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "query", + "name": "token", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Payments" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/payments/connect/callback": { + "get": { + "operationId": "api_v1_payments_connect_callback_retrieve", + "summary": "Complete Stripe Connect onboarding", + "parameters": [ + { + "in": "query", + "name": "code", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "error", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "state", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Payments" + ], + "responses": { + "302": { + "description": "Redirect to trusted staff settings." + } + } + } + }, + "/api/v1/procurement/makerspace/{makerspace_id}/to-buy": { + "get": { + "operationId": "api_v1_procurement_makerspace_to_buy_list", + "summary": "List to-buy items for a makerspace", + "parameters": [ + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "approved", + "cancelled", + "ordered", + "received", + "requested" + ] + }, + "description": "Filter by procurement item status." + } + ], + "tags": [ + "Procurement" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ToBuyItem" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + }, + "post": { + "operationId": "api_v1_procurement_makerspace_to_buy_create", + "summary": "Add a to-buy item", + "parameters": [ + { + "in": "query", + "name": "kind", + "schema": { + "type": "string", + "enum": [ + "hardware", + "printing" + ] + }, + "description": "Stream to add to. Honored only for makerspace admins/superadmin; other roles are auto-tagged by role." + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyItem" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ToBuyItem" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ToBuyItem" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyItem" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/procurement/makerspace/{makerspace_id}/to-buy/export": { + "get": { + "operationId": "api_v1_procurement_makerspace_to_buy_export_retrieve", + "summary": "Export to-buy items as CSV or XLSX", + "parameters": [ + { + "in": "query", + "name": "format", + "schema": { + "type": "string", + "enum": [ + "csv", + "xlsx" + ] + } + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "approved", + "cancelled", + "ordered", + "received", + "requested" + ] + }, + "description": "Filter by procurement item status." + } + ], + "tags": [ + "Procurement" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "text/csv": { + "schema": { + "type": "string" + } + }, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/procurement/makerspace/{makerspace_id}/to-buy/machine-types": { + "get": { + "operationId": "api_v1_procurement_makerspace_to_buy_machine_types_retrieve", + "summary": "List machine types available for a new to-buy item", + "parameters": [ + { + "in": "query", + "name": "kind", + "schema": { + "type": "string", + "enum": [ + "hardware", + "printing" + ] + }, + "description": "Stream to add to. Honored only for makerspace admins/superadmin; other roles are auto-tagged by role." + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyMachineTypeOptions" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/procurement/to-buy/{id}": { + "get": { + "operationId": "api_v1_procurement_to_buy_retrieve", + "summary": "Retrieve a to-buy item", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyItem" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + }, + "patch": { + "operationId": "api_v1_procurement_to_buy_partial_update", + "summary": "Update a to-buy item", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedToBuyItem" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedToBuyItem" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedToBuyItem" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyItem" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + }, + "delete": { + "operationId": "api_v1_procurement_to_buy_destroy", + "summary": "Delete a to-buy item", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/procurement/to-buy/{id}/move-to-inventory": { + "post": { + "operationId": "api_v1_procurement_to_buy_move_to_inventory_create", + "summary": "Move a received hardware to-buy item into inventory", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoveToInventoryRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MoveToInventoryRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MoveToInventoryRequest" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyItem" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/procurement/to-buy/{id}/move-to-printing": { + "post": { + "operationId": "api_v1_procurement_to_buy_move_to_printing_create", + "summary": "Move a received printing to-buy item into printing assets", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoveToPrintingRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MoveToPrintingRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MoveToPrintingRequest" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyItem" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/procurement/to-buy/{id}/receipts": { + "get": { + "operationId": "api_v1_procurement_to_buy_receipts_list", + "summary": "List procurement receipts for a to-buy item", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ToBuyReceipt" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + }, + "post": { + "operationId": "api_v1_procurement_to_buy_receipts_create", + "summary": "Finalize an uploaded procurement receipt", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyReceiptFinalize" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ToBuyReceiptFinalize" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ToBuyReceiptFinalize" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyReceipt" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "503": { + "description": "Receipt storage is unavailable." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/procurement/to-buy/{id}/receipts/presign": { + "post": { + "operationId": "api_v1_procurement_to_buy_receipts_presign_create", + "summary": "Create a procurement receipt upload URL", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyReceiptPresign" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ToBuyReceiptPresign" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ToBuyReceiptPresign" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyReceiptUploadResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "503": { + "description": "Receipt storage is unavailable." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/procurement/to-buy/receipts/{id}": { + "delete": { + "operationId": "api_v1_procurement_to_buy_receipts_destroy", + "summary": "Delete a procurement receipt", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/procurement/to-buy/receipts/{id}/url": { + "get": { + "operationId": "api_v1_procurement_to_buy_receipts_url_retrieve", + "summary": "Create a signed procurement receipt view URL", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyReceiptUrl" + } + } + }, + "description": "" + }, + "503": { + "description": "Receipt storage is unavailable." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/bookings/calendar/{raw_token}.ics": { + "get": { + "operationId": "api_v1_public_bookings_calendar_.ics_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "raw_token", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Member bookings" + ], + "responses": { + "200": { + "content": { + "text/calendar": { + "schema": { + "type": "string" + } + } + }, + "description": "Own reservations as RFC 5545 ICS." + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "429": { + "description": "Rate limit exceeded; retry after the interval in Retry-After." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/event-calendar/{raw_token}.ics": { + "get": { + "operationId": "api_v1_public_event_calendar_.ics_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "raw_token", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public events" + ], + "responses": { + "200": { + "content": { + "text/calendar": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "RFC 5545 calendar (`text/calendar; charset=utf-8`)." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Calendar not found." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/events/": { + "get": { + "operationId": "api_v1_public_events_list", + "parameters": [ + { + "in": "query", + "name": "category", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Full-text search over title, location and description; supports \"phrases\" and -exclusions, tolerates typos in the title." + }, + { + "in": "query", + "name": "tag", + "schema": { + "type": "string" + } + } + ], + "tags": [ + "Public events" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicEvent" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid request." + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Event not found." + }, + "429": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/events/{public_token}/": { + "get": { + "operationId": "api_v1_public_events_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public events" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicEvent" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid request." + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Event not found." + }, + "429": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/events/{public_token}/calendar.ics": { + "get": { + "operationId": "api_v1_public_events_calendar.ics_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public events" + ], + "responses": { + "200": { + "content": { + "text/calendar": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "RFC 5545 calendar (`text/calendar; charset=utf-8`)." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Events module unavailable." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Calendar not found." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/events/{public_token}/eligibility/": { + "get": { + "operationId": "api_v1_public_events_eligibility_retrieve", + "description": "Account-specific advisory only, private/no-store. Owned live registrations may renew host consent after signup closes, until the event ends. No membership or eligibility-source identities. Admission rechecks all proofs and current host consent transactionally.", + "summary": "Read private organization audience eligibility", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public events" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventEligibility" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/events/{public_token}/feedback/": { + "get": { + "operationId": "api_v1_public_events_feedback_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public events" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackForm" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid feedback answers." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required for a certificate." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Feedback form not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Feedback retry conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + } + } + }, + "post": { + "operationId": "api_v1_public_events_feedback_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public events" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackSubmission" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/FeedbackSubmission" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/FeedbackSubmission" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + }, + {} + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackSubmissionResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid feedback answers." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required for a certificate." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Feedback form not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Feedback retry conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/events/{public_token}/register/": { + "post": { + "operationId": "api_v1_public_events_register_create", + "description": "Invite-only admission accepts a code or a matching verified account email. Code-free unverified admission returns email_verification_required; all normal admission, capacity and waiver rules apply.", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public events" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicEventRegistrationInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicEventRegistrationInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicEventRegistrationInput" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicEventRegistrationResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid request." + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Event not found." + }, + "429": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Rate limit exceeded." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "An active authenticated account is required; member audiences also require active membership and the current waiver." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event state conflict or cancelled paid registration cannot be reused." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/events/{public_token}/website/": { + "get": { + "operationId": "api_v1_public_events_website_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public event website" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Website" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Unknown, hidden, disabled, unreleased or expired website content." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Image storage is unavailable." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/events/{public_token}/website/assets/{asset_id}/": { + "get": { + "operationId": "api_v1_public_events_website_assets_retrieve", + "parameters": [ + { + "in": "path", + "name": "asset_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public event website" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "Released WebP image." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Unknown, hidden, disabled, unreleased or expired website content." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Image storage is unavailable." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/events/{public_token}/website/schedule/": { + "get": { + "operationId": "api_v1_public_events_website_schedule_list", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public event website" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebsiteSession" + } + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Unknown, hidden, disabled, unreleased or expired website content." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Image storage is unavailable." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/events/{public_token}/website/sections/{slug}/": { + "get": { + "operationId": "api_v1_public_events_website_sections_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + }, + { + "in": "path", + "name": "slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public event website" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebsiteSection" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Unknown, hidden, disabled, unreleased or expired website content." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Image storage is unavailable." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/events/{public_token}/website/sessions/{slug}/": { + "get": { + "operationId": "api_v1_public_events_website_sessions_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + }, + { + "in": "path", + "name": "slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public event website" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebsiteSession" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Unknown, hidden, disabled, unreleased or expired website content." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Image storage is unavailable." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/events/{public_token}/website/speakers/{slug}/": { + "get": { + "operationId": "api_v1_public_events_website_speakers_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + }, + { + "in": "path", + "name": "slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public event website" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebsiteSection" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Unknown, hidden, disabled, unreleased or expired website content." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Image storage is unavailable." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/inventory/": { + "get": { + "operationId": "api_v1_public_inventory_list", + "description": "List public inventory products for a public makerspace.", + "summary": "List public inventory products", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "query", + "name": "category", + "schema": { + "type": "string" + }, + "description": "Filter public products by category slug." + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "description": "Public makerspace code (for example TSEL) or slug.", + "required": true + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Full-text search over public name and description; supports \"phrases\" and -exclusions, tolerates typos in the name." + }, + { + "in": "query", + "name": "sort", + "schema": { + "type": "string", + "enum": [ + "most_used", + "name", + "popular" + ] + }, + "description": "Sort public products." + } + ], + "tags": [ + "Public inventory" + ], + "security": [ + { + "jwtAuth": [] + }, + {} + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedPublicProductList" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicInventoryError" + } + } + }, + "description": "Public inventory module or loans feature is disabled." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicInventoryError" + } + } + }, + "description": "Invalid authentication credentials." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicInventoryError" + } + } + }, + "description": "Public API client is not authorized." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicInventoryError" + } + } + }, + "description": "Item or public makerspace not found, or hidden by edition." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicInventoryError" + } + } + }, + "description": "Public read rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/inventory/{id}/": { + "get": { + "operationId": "api_v1_public_inventory_retrieve", + "description": "Read-only public catalogue detail. Private, archived and cross-space items share the same not-found response.", + "summary": "Get public inventory product detail", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public inventory" + ], + "security": [ + { + "jwtAuth": [] + }, + {} + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicProduct" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicInventoryError" + } + } + }, + "description": "Public inventory module or loans feature is disabled." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicInventoryError" + } + } + }, + "description": "Invalid authentication credentials." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicInventoryError" + } + } + }, + "description": "Public API client is not authorized." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicInventoryError" + } + } + }, + "description": "Item or public makerspace not found, or hidden by edition." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicInventoryError" + } + } + }, + "description": "Public read rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/inventory/{id}/watch/": { + "post": { + "operationId": "api_v1_public_inventory_watch_create", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "api" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WatchResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + }, + "description": "" + } + } + }, + "delete": { + "operationId": "api_v1_public_inventory_watch_destroy", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "api" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/inventory/categories/": { + "get": { + "operationId": "api_v1_public_inventory_categories_list", + "summary": "List public inventory categories", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public inventory" + ], + "security": [ + { + "jwtAuth": [] + }, + {} + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicCategory" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicInventoryError" + } + } + }, + "description": "Public inventory module or loans feature is disabled." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicInventoryError" + } + } + }, + "description": "Invalid authentication credentials." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicInventoryError" + } + } + }, + "description": "Public API client is not authorized." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicInventoryError" + } + } + }, + "description": "Item or public makerspace not found, or hidden by edition." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicInventoryError" + } + } + }, + "description": "Public read rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/invitation-requests": { + "post": { + "operationId": "api_v1_public_invitation_requests_create", + "summary": "Ask a makerspace to invite you", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Memberships" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvitationRequestCreate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/InvitationRequestCreate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/InvitationRequestCreate" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + }, + {} + ], + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvitationRequestAck" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "description": "Unknown makerspace." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/machine-service-requests": { + "post": { + "operationId": "api_v1_public_machine_service_requests_create", + "summary": "Submit a machine service request as a member", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public machine service" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicMachineServiceSubmit" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicMachineServiceSubmit" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicMachineServiceSubmit" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicMachineServiceSubmitResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid machine service request input." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Active membership, waiver acceptance, and presence are required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Makerspace or machine not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Machine service request conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Request rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/machine-service/3d-printer/consumable-pools": { + "get": { + "operationId": "api_v1_public_machine_service_3d_printer_consumable_pools_list", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public machine service" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicPrinterPool" + } + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/machine-service/3d-printer/queues": { + "get": { + "operationId": "api_v1_public_machine_service_3d_printer_queues_list", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public machine service" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicPrinterQueue" + } + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/machine-service/3d-printer/requests": { + "post": { + "operationId": "api_v1_public_machine_service_3d_printer_requests_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public machine service" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicPrinterSubmit" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicPrinterSubmit" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicPrinterSubmit" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicPrinterSubmitResponse" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/machine-service/3d-printer/uploads": { + "post": { + "operationId": "api_v1_public_machine_service_3d_printer_uploads_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public machine service" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicPrinterUpload" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicPrinterUpload" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicPrinterUpload" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicPrinterSubmitResponse" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/machine-types/{type_id}/intake": { + "get": { + "operationId": "api_v1_public_machine_types_intake_retrieve", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "type_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "api" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicTypeIntake" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/machines": { + "get": { + "operationId": "api_v1_public_machines_list", + "description": "List active machines published by a public makerspace.", + "summary": "List public machines", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "description": "Public makerspace code or slug.", + "required": true + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Full-text search over name, location, firmware version and notes; tolerates typos in the name." + } + ], + "tags": [ + "Public machines" + ], + "security": [ + { + "jwtAuth": [] + }, + {} + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedPublicMachineList" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "module": { + "type": "string" + } + }, + "required": [ + "module" + ] + } + } + }, + "description": "Machines module disabled." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Public makerspace not found." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid API credentials." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "API client not authorized." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/machines/{id}": { + "get": { + "operationId": "api_v1_public_machines_retrieve", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "api" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicMachineDetail" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/machines/details": { + "get": { + "operationId": "api_v1_public_machines_details_list", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + } + ], + "tags": [ + "api" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedPublicMachineDetailList" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/membership-admission": { + "get": { + "operationId": "api_v1_public_membership_admission_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Memberships" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicAdmission" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/membership-requests": { + "post": { + "operationId": "api_v1_public_membership_requests_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Memberships" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipRequestCreate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MembershipRequestCreate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MembershipRequestCreate" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipOutcome" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/my-bookings/": { + "get": { + "operationId": "api_v1_public_my_bookings_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "period", + "schema": { + "enum": [ + "upcoming", + "past" + ], + "type": "string", + "default": "upcoming", + "minLength": 1 + }, + "description": "* `upcoming` - upcoming\n* `past` - past" + } + ], + "tags": [ + "Member bookings" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberBookingList" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid request or bookings disabled." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Sign-in required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Active account required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Makerspace or own booking not found." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/my-bookings/{public_token}/calendar.ics": { + "get": { + "operationId": "api_v1_public_my_bookings_calendar.ics_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Member bookings" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "text/calendar": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/my-bookings/{public_token}/cancel/": { + "post": { + "operationId": "api_v1_public_my_bookings_cancel_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Member bookings" + ], + "requestBody": { + "content": { + "type": { + "schema": { + "type": "object", + "additionalProperties": {}, + "description": "Unspecified request body" + } + }, + "properties": { + "schema": {} + }, + "additionalProperties": { + "schema": { + "type": "object", + "additionalProperties": {}, + "description": "Unspecified request body" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberBooking" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid request or bookings disabled." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Sign-in required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Active account required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Makerspace or own booking not found." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Tenant frozen for migration." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Tenant write gate unavailable." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Cancellation cutoff or state conflict." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/my-bookings/{public_token}/confirmation/": { + "get": { + "operationId": "api_v1_public_my_bookings_confirmation_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Member bookings" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingConfirmation" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/my-bookings/{public_token}/confirmation.png": { + "get": { + "operationId": "api_v1_public_my_bookings_confirmation.png_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Member bookings" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/my-bookings/{public_token}/pass.pdf": { + "get": { + "operationId": "api_v1_public_my_bookings_pass.pdf_retrieve", + "description": "Authenticated booking owner only. Confirmed/completed answer-free pass; bookings.passes required. No membership module needed.", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Member bookings" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "Private answer-free booking pass. Never contains a bearer QR." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/my-bookings/{public_token}/reschedule/": { + "post": { + "operationId": "api_v1_public_my_bookings_reschedule_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Member bookings" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingReschedule" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/BookingReschedule" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/BookingReschedule" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberBooking" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid request or bookings disabled." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Sign-in required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Active account required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Makerspace or own booking not found." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Cutoff, state or slot conflict." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Tenant frozen." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Tenant gate unavailable." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/my-bookings/{public_token}/rotate-checkin/": { + "post": { + "operationId": "api_v1_public_my_bookings_rotate_checkin_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Member bookings" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingConfirmation" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/my-bookings/calendar/": { + "get": { + "operationId": "api_v1_public_my_bookings_calendar_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Member bookings" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedState" + } + } + }, + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + } + } + }, + "post": { + "operationId": "api_v1_public_my_bookings_calendar_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Member bookings" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedIssued" + } + } + }, + "description": "" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." + }, + "503": { + "description": "A required dependency is unavailable." + } + } + }, + "delete": { + "operationId": "api_v1_public_my_bookings_calendar_destroy", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Member bookings" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + }, + "400": { + "description": "Validation failed, or the makerspace module this route belongs to is off." + }, + "401": { + "description": "Not signed in." + }, + "403": { + "description": "Signed in, but this action or makerspace is not permitted (or the secret is wrong)." + }, + "404": { + "description": "Not found, not visible to this actor, or the surface is not enabled here." + }, + "423": { + "description": "Makerspace writes are frozen for tenant migration." + }, + "503": { + "description": "A required dependency is unavailable." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/presence-sessions": { + "post": { + "operationId": "api_v1_public_presence_sessions_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Presence" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PresenceStart" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PresenceStart" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PresenceStart" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PresenceSession" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid input." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Membership permission required." + }, + "404": { + "description": "Makerspace not found." + }, + "429": { + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/presence-sessions/current": { + "get": { + "operationId": "api_v1_public_presence_sessions_current_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Presence" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PresenceCurrent" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid input." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Membership permission required." + }, + "404": { + "description": "Makerspace not found." + }, + "429": { + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/presence-sessions/current/end": { + "post": { + "operationId": "api_v1_public_presence_sessions_current_end_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Presence" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PresenceCurrent" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid input." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Membership permission required." + }, + "404": { + "description": "Makerspace not found." + }, + "429": { + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/requests": { + "post": { + "operationId": "api_v1_public_requests_create", + "summary": "Submit public borrow request", + "parameters": [ + { + "in": "header", + "name": "Idempotency-Key", + "schema": { + "type": "string" + }, + "description": "Required for account-less submissions. Reusing a key with the same payload returns the original request; a different payload is rejected." + }, + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public requests" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestSubmit" + }, + "examples": { + "SubmitPublicEquipmentRequest": { + "value": { + "contact_name": "Shaan Shoukath", + "contact_email": "shaans@example.com", + "contact_phone": "+919876543210", + "requested_for": "Electronics workshop diagnostics", + "items": [ + { + "product_id": 42, + "quantity": 2 + } + ] + }, + "summary": "Submit public equipment request" + } + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/RequestSubmit" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/RequestSubmit" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + }, + {} + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestSubmitResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {}, + "properties": { + "feature": { + "type": "string" + } + } + } + } + }, + "description": "Invalid submission or loans feature disabled." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Too many requests." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Service unavailable." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Tenant frozen for migration." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/spaces/": { + "get": { + "operationId": "api_v1_public_spaces_list", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public bookings" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicBookableSpace" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid request." + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Space not found." + }, + "429": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/spaces/{public_token}/": { + "get": { + "operationId": "api_v1_public_spaces_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public booking website" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicBookingResource" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Unknown, private, inactive, disabled or unreleased content." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Image storage unavailable." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/spaces/{public_token}/availability/": { + "get": { + "operationId": "api_v1_public_spaces_availability_retrieve", + "parameters": [ + { + "in": "query", + "name": "ends_at", + "schema": { + "type": "string", + "format": "date-time" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + }, + { + "in": "query", + "name": "starts_at", + "schema": { + "type": "string", + "format": "date-time" + }, + "required": true + } + ], + "tags": [ + "Public bookings" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicSpaceAvailability" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid request." + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Space not found." + }, + "429": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/spaces/{public_token}/book/": { + "post": { + "operationId": "api_v1_public_spaces_book_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public bookings" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicBookingInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicBookingInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicBookingInput" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "type": "string", + "enum": [ + "pending", + "confirmed" + ] + } + }, + "required": [ + "status" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "type": "string", + "enum": [ + "pending", + "confirmed" + ] + }, + "public_token": { + "type": "string", + "format": "uuid" + }, + "confirmation_available": { + "type": "boolean" + } + }, + "required": [ + "status", + "public_token", + "confirmation_available" + ] + } + ] + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid request." + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Space not found." + }, + "429": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Rate limit exceeded." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Active account and any existing membership waiver acceptance are required." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Booking conflict." + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Tenant frozen for migration." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Tenant write gate unavailable." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/spaces/{public_token}/website/": { + "get": { + "operationId": "api_v1_public_spaces_website_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public booking website" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingWebsite" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Unknown, private, inactive, disabled or unreleased content." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Image storage unavailable." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/spaces/{public_token}/website/assets/{asset_id}/": { + "get": { + "operationId": "api_v1_public_spaces_website_assets_retrieve", + "parameters": [ + { + "in": "path", + "name": "asset_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public booking website" + ], + "responses": { + "200": { + "content": { + "image/webp": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "Live-gated released WebP image." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Unknown, private, inactive, disabled or unreleased content." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Image storage unavailable." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/stats/": { + "get": { + "operationId": "api_v1_public_stats_retrieve", + "description": "Get public activity stats for a public makerspace.", + "summary": "Get public makerspace stats", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "description": "Public makerspace code (for example TSEL) or slug.", + "required": true + } + ], + "tags": [ + "Public inventory" + ], + "security": [ + { + "jwtAuth": [] + }, + {} + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicStats" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/tools/checklist": { + "post": { + "operationId": "api_v1_public_tools_checklist_create", + "summary": "Preview kit components for a scanned self-checkout tool", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public requests" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberKitPreviewInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MemberKitPreviewInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MemberKitPreviewInput" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KitPreview" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/tools/checkout": { + "post": { + "operationId": "api_v1_public_tools_checkout_create", + "summary": "Check out a public tool by QR", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public requests" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicToolCheckout" + }, + "examples": { + "PublicQRToolCheckout": { + "value": { + "payload": "BOX-ABC123", + "requester_name": "Shaan Shoukath", + "contact_email": "shaans@example.com", + "contact_phone": "+919876543210", + "evidence_id": 122, + "remark": "Borrowing for electronics workshop diagnostics." + }, + "summary": "Public QR tool checkout" + } + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicToolCheckout" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicToolCheckout" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicToolLoan" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Too many requests." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Service unavailable." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/tools/evidence-url": { + "post": { + "operationId": "api_v1_public_tools_evidence_url_create", + "summary": "Create a public self-checkout evidence upload URL", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public requests" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicToolEvidenceUrlRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicToolEvidenceUrlRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicToolEvidenceUrlRequest" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EvidenceUrlResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Too many requests." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Service unavailable." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/tools/return": { + "post": { + "operationId": "api_v1_public_tools_return_create", + "summary": "Return a public tool by QR", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public requests" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicToolScan" + }, + "examples": { + "PublicQRToolReturnScan": { + "value": { + "identifier": "shaans@example.com", + "payload": "BOX-ABC123", + "evidence_id": 123, + "remark": "Returned to the electronics shelf in good condition." + }, + "summary": "Public QR tool return scan" + } + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicToolScan" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicToolScan" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicToolLoan" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Too many requests." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Service unavailable." + } + } + } + }, + "/api/v1/public/machine-service/3d-printer/requests/{public_token}/status": { + "get": { + "operationId": "api_v1_public_machine_service_3d_printer_requests_status_retrieve", + "parameters": [ + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public machine service" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicPrinterStatus" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/makerspaces/": { + "get": { + "operationId": "api_v1_public_makerspaces_list", + "description": "List makerspaces that have a public site enabled.", + "summary": "List public makerspaces", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + } + ], + "tags": [ + "Public inventory" + ], + "security": [ + { + "jwtAuth": [] + }, + {} + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicMakerspace" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicInventoryError" + } + } + }, + "description": "Public inventory module or loans feature is disabled." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicInventoryError" + } + } + }, + "description": "Invalid authentication credentials." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicInventoryError" + } + } + }, + "description": "Public API client is not authorized." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicInventoryError" + } + } + }, + "description": "Item or public makerspace not found, or hidden by edition." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicInventoryError" + } + } + }, + "description": "Public read rate limit exceeded." + } + } + } + }, + "/api/v1/public/organizations/{slug}/": { + "get": { + "operationId": "api_v1_public_organizations_retrieve", + "summary": "Retrieve a public organization profile", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public organizations" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicOrganization" + } + } + }, + "description": "" + }, + "404": { + "description": "Organization not found." + }, + "429": { + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/organizations/{slug}/events/": { + "get": { + "operationId": "api_v1_public_organizations_events_retrieve", + "summary": "List public events organized across makerspaces", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public organizations" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicOrganizationEventList" + } + } + }, + "description": "" + }, + "404": { + "description": "Organization not found." + }, + "429": { + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/requests/{public_token}/status": { + "get": { + "operationId": "api_v1_public_requests_status_retrieve", + "summary": "Get request status by public token", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public requests" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicRequestStatus" + }, + "examples": { + "PublicRequestStatus": { + "value": { + "public_token": "4f2b93e1-6ef4-41c2-8407-7f26bb3b2d8f", + "requested_for": "Electronics workshop diagnostics", + "status": "pending_approval", + "rejection_reason": "", + "created_at": "2026-06-11T10:30:00Z", + "items": [ + { + "product_name": "Soldering Iron", + "requested_quantity": 2 + } + ] + }, + "summary": "Public request status" + } + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Too many requests." + } + } + } + }, + "/api/v1/recovery": { + "get": { + "operationId": "api_v1_recovery_retrieve", + "summary": "Get deployment quarantine and residual-risk state", + "tags": [ + "Backup recovery" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecoveryState" + } + } + }, + "description": "" + }, + "401": { + "description": "Authentication is required." + }, + "403": { + "description": "The authenticated actor is not authorized." + } + } + }, + "post": { + "operationId": "api_v1_recovery_create", + "summary": "Acknowledge residual risk and lift quarantine", + "tags": [ + "Backup recovery" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecoveryAcknowledge" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/RecoveryAcknowledge" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/RecoveryAcknowledge" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecoveryState" + } + } + }, + "description": "" + }, + "400": { + "description": "The request is invalid for the current lifecycle state." + }, + "401": { + "description": "Authentication is required." + }, + "403": { + "description": "The authenticated actor is not authorized." + } + } + } + }, + "/api/v1/webhooks/razorpay/{public_code}": { + "post": { + "operationId": "api_v1_webhooks_razorpay_create", + "description": "Verifies the X-Razorpay-Signature HMAC over request.body using the addressed makerspace's webhook secret before applying an idempotent event.", + "summary": "Receive a makerspace Razorpay webhook", + "parameters": [ + { + "in": "path", + "name": "public_code", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Payments" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookResponse" + } + } + }, + "description": "Verified event acknowledged (acted on or deliberately ignored)." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookResponse" + } + } + }, + "description": "Invalid signature, payload, or configuration." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookResponse" + } + } + }, + "description": "Makerspace was not found." + } + } + } + }, + "/api/v1/webhooks/stripe/{public_code}": { + "post": { + "operationId": "api_v1_webhooks_stripe_create", + "description": "Verifies the Stripe signature using request.body and the addressed makerspace's webhook secret before applying an idempotent event.", + "summary": "Receive a makerspace Stripe webhook", + "parameters": [ + { + "in": "path", + "name": "public_code", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Payments" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookResponse" + } + } + }, + "description": "Verified event acknowledged (acted on or deliberately ignored)." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookResponse" + } + } + }, + "description": "Invalid signature, payload, or configuration." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookResponse" + } + } + }, + "description": "Makerspace was not found." + } + } + } + }, + "/api/v1/webhooks/stripe/connect": { + "post": { + "operationId": "api_v1_webhooks_stripe_connect_create", + "summary": "Receive a platform Stripe Connect webhook", + "tags": [ + "Payments" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookResponse" + } + } + }, + "description": "Verified event acknowledged (acted on or deliberately ignored)." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookResponse" + } + } + }, + "description": "Invalid signature, payload, or configuration." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookResponse" + } + } + }, + "description": "Makerspace was not found." + } + } + } + }, + "/embed/{makerspace_slug}/{kind}/{public_token}/": { + "get": { + "operationId": "embed_retrieve", + "parameters": [ + { + "in": "path", + "name": "kind", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public websites" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Self-contained HTML with exact framing CSP." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid discovery filters." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Unknown or unavailable public content." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/robots.txt": { + "get": { + "operationId": "robots.txt_retrieve", + "tags": [ + "Public websites" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Public crawler policy and origin sitemap." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid discovery filters." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Unknown or unavailable public content." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/share/{kind}/{public_token}/": { + "get": { + "operationId": "share_retrieve", + "parameters": [ + { + "in": "path", + "name": "kind", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public websites" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Noindex HTML with public OG/Twitter metadata; server cache 600s, live-gated." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid discovery filters." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Unknown or unavailable public content." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/sitemap.xml": { + "get": { + "operationId": "sitemap.xml_retrieve", + "tags": [ + "Public websites" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Origin-scoped XML sitemap of live public subjects." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid discovery filters." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Unknown or unavailable public content." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + } + } + } + } + }, + "components": { + "schemas": { + "AcceptQuantity": { + "type": "object", + "properties": { + "item_id": { + "type": "integer" + }, + "quantity": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "item_id", + "quantity" + ] + }, + "AcceptRequest": { + "type": "object", + "properties": { + "accepted_quantities": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AcceptQuantity" + } + } + } + }, + "AccessLevelEnum": { + "enum": [ + "operate", + "manage", + "full" + ], + "type": "string", + "description": "* `operate` - Operate\n* `manage` - Manage\n* `full` - Full" + }, + "AccessStatusEnum": { + "enum": [ + "active", + "restricted", + "suspended" + ], + "type": "string", + "description": "* `active` - Active\n* `restricted` - Restricted\n* `suspended` - Suspended" + }, + "AccountDeletionBlocker": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "makerspace_id": { + "type": "integer", + "nullable": true + }, + "makerspace_slug": { + "type": "string", + "nullable": true + }, + "makerspace_member_url": { + "type": "string", + "nullable": true + }, + "organization_url": { + "type": "string", + "nullable": true + }, + "organization_id": { + "type": "integer", + "nullable": true + }, + "count": { + "type": "integer", + "minimum": 1 + }, + "detail": { + "type": "string" + } + }, + "required": [ + "code", + "count", + "detail", + "makerspace_id", + "makerspace_member_url", + "makerspace_slug", + "organization_id", + "organization_url" + ] + }, + "AccountDeletionForbidden": { + "oneOf": [ + { + "$ref": "#/components/schemas/HardwareRequestError" + }, + { + "$ref": "#/components/schemas/AccountDeletionRefused" + } + ] + }, + "AccountDeletionPreview": { + "type": "object", + "properties": { + "deletable": { + "type": "boolean" + }, + "restorable": { + "type": "boolean" + }, + "restore_reason": { + "type": "string" + }, + "blockers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AccountDeletionBlocker" + } + }, + "reauth": { + "$ref": "#/components/schemas/AccountDeletionReauth" + } + }, + "required": [ + "blockers", + "deletable", + "reauth", + "restorable", + "restore_reason" + ] + }, + "AccountDeletionReauth": { + "type": "object", + "properties": { + "required": { + "type": "boolean" + }, + "methods": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "methods", + "required" + ] + }, + "AccountDeletionRefused": { + "type": "object", + "properties": { + "blockers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AccountDeletionBlocker" + } + } + }, + "required": [ + "blockers" + ] + }, + "AccountDeletionRequest": { + "type": "object", + "properties": { + "confirm": { + "$ref": "#/components/schemas/ConfirmEnum" + } + }, + "required": [ + "confirm" + ] + }, + "AccountDeletionResult": { + "type": "object", + "properties": { + "deleted_at": { + "type": "string", + "format": "date-time" + }, + "purge_after": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "deleted_at", + "purge_after" + ] + }, + "AccountFailure": { + "type": "object", + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "detail" + ] + }, + "AccountProfile": { + "type": "object", + "properties": { + "display_name": { + "type": "string", + "maxLength": 200 + }, + "first_name": { + "type": "string", + "maxLength": 150 + }, + "last_name": { + "type": "string", + "maxLength": 150 + }, + "phone": { + "type": "string", + "maxLength": 32 + } + } + }, + "AccountRestoreEmail": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "maxLength": 254 + }, + "token": { + "type": "string", + "writeOnly": true, + "maxLength": 512 + }, + "new_password": { + "type": "string", + "writeOnly": true, + "maxLength": 128 + } + }, + "required": [ + "email", + "new_password", + "token" + ] + }, + "AccountRestorePhone": { + "type": "object", + "properties": { + "phone": { + "type": "string", + "maxLength": 32 + }, + "delivery": { + "allOf": [ + { + "$ref": "#/components/schemas/DeliveryEnum" + } + ], + "default": "web" + }, + "client_platform": { + "allOf": [ + { + "$ref": "#/components/schemas/ClientPlatformEnum" + } + ], + "default": "web" + }, + "challenge": { + "type": "string", + "writeOnly": true, + "maxLength": 512 + }, + "code": { + "type": "string", + "maxLength": 6, + "minLength": 6 + }, + "attestation": { + "allOf": [ + { + "$ref": "#/components/schemas/DeviceAttestationPayload" + } + ], + "writeOnly": true + }, + "new_password": { + "type": "string", + "writeOnly": true, + "maxLength": 128 + } + }, + "required": [ + "code", + "new_password", + "phone" + ] + }, + "AccountRestoreResult": { + "type": "object", + "properties": { + "restored": { + "type": "boolean" + }, + "detail": { + "type": "string" + } + }, + "required": [ + "detail", + "restored" + ] + }, + "AccountRestoreSocialComplete": { + "type": "object", + "properties": { + "state": { + "type": "string", + "writeOnly": true, + "maxLength": 512 + }, + "nonce": { + "type": "string", + "writeOnly": true, + "maxLength": 512 + }, + "id_token": { + "type": "string", + "writeOnly": true, + "default": "", + "maxLength": 16384 + }, + "code": { + "type": "string", + "writeOnly": true, + "default": "", + "maxLength": 4096 + }, + "challenge": { + "type": "string", + "writeOnly": true, + "default": "", + "maxLength": 512 + }, + "attestation": { + "allOf": [ + { + "$ref": "#/components/schemas/DeviceAttestationPayload" + } + ], + "writeOnly": true + } + }, + "required": [ + "nonce", + "state" + ] + }, + "AccountRestoreSocialResult": { + "type": "object", + "properties": { + "authorization_url": { + "type": "string", + "format": "uri" + }, + "state": { + "type": "string" + }, + "nonce": { + "type": "string" + }, + "expires_in": { + "type": "integer" + } + }, + "required": [ + "authorization_url", + "expires_in", + "nonce", + "state" + ] + }, + "AccountRestoreSocialStart": { + "type": "object", + "properties": { + "delivery": { + "allOf": [ + { + "$ref": "#/components/schemas/DeliveryEnum" + } + ], + "default": "web" + }, + "client_platform": { + "allOf": [ + { + "$ref": "#/components/schemas/ClientPlatformEnum" + } + ], + "default": "web" + }, + "challenge": { + "type": "string", + "writeOnly": true, + "maxLength": 512 + }, + "username": { + "type": "string", + "maxLength": 150 + }, + "provider": { + "type": "string", + "maxLength": 100 + }, + "redirect_uri": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "default": "", + "maxLength": 1000 + }, + { + "type": "string", + "maxLength": 0 + } + ] + } + }, + "required": [ + "provider", + "username" + ] + }, + "AccountSession": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/AccountSessionKindEnum" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "last_used_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "current": { + "type": "boolean" + } + }, + "required": [ + "created_at", + "current", + "expires_at", + "id", + "kind", + "last_used_at" + ] + }, + "AccountSessionKindEnum": { + "enum": [ + "web", + "device" + ], + "type": "string", + "description": "* `web` - web\n* `device` - device" + }, + "AccountSessionPage": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "format": "uri", + "nullable": true + }, + "previous": { + "type": "string", + "format": "uri", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AccountSession" + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] + }, + "AccountingJournalReport": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/JournalLine" + } + } + }, + "required": [ + "rows", + "typed_rows" + ] + }, + "Ack": { + "type": "object", + "properties": { + "lease_token": { + "type": "string", + "maxLength": 128, + "minLength": 32 + } + }, + "required": [ + "lease_token" + ] + }, + "ActiveLoansReport": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActiveLoansReportRow" + } + } + }, + "required": [ + "rows", + "typed_rows" + ] + }, + "ActiveLoansReportRow": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "requester": { + "type": "string" + }, + "status": { + "type": "string" + }, + "issued_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "count": { + "type": "integer", + "maximum": 1, + "minimum": 1, + "description": "One loan per detail row; sum to count issued loans in this result." + } + }, + "required": [ + "count", + "id", + "issued_at", + "requester", + "status" + ] + }, + "AddressingStyleEnum": { + "enum": [ + "auto", + "path", + "virtual" + ], + "type": "string", + "description": "* `auto` - Auto\n* `path` - Path\n* `virtual` - Virtual host" + }, + "AdminMembership": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "status": { + "$ref": "#/components/schemas/Status37fEnum" + }, + "user": { + "allOf": [ + { + "$ref": "#/components/schemas/AdminMembershipUser" + } + ], + "readOnly": true + }, + "assigned_role": { + "allOf": [ + { + "$ref": "#/components/schemas/AdminMembershipRole" + } + ], + "nullable": true, + "readOnly": true + }, + "role_ids": { + "type": "array", + "items": { + "type": "integer" + }, + "readOnly": true + }, + "roles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MembershipRole" + }, + "readOnly": true + }, + "can_refer": { + "type": "boolean" + }, + "can_verify": { + "type": "boolean" + }, + "verified_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "activated_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "revocation_reason": { + "type": "string" + }, + "waiver_accepted_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "waiver_version_accepted": { + "type": "string", + "nullable": true, + "maxLength": 64 + }, + "waiver_current": { + "type": "boolean", + "readOnly": true + }, + "waiver_required": { + "type": "boolean", + "readOnly": true + }, + "payment": { + "allOf": [ + { + "$ref": "#/components/schemas/StaffPaymentSummary" + } + ], + "nullable": true, + "readOnly": true + }, + "vouches": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Vouch" + }, + "readOnly": true + } + }, + "required": [ + "assigned_role", + "id", + "payment", + "role_ids", + "roles", + "user", + "vouches", + "waiver_current", + "waiver_required" + ] + }, + "AdminMembershipRole": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "slug" + ] + }, + "AdminMembershipUser": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + }, + "display_name": { + "type": "string" + } + }, + "required": [ + "display_name", + "email", + "id", + "username" + ] + }, + "AdminRequest": { + "type": "object", + "properties": { + "has_kit_checklist": { + "type": "boolean", + "readOnly": true + }, + "id": { + "type": "integer", + "readOnly": true + }, + "makerspace_id": { + "type": "integer", + "readOnly": true + }, + "requester_username": { + "type": "string", + "readOnly": true + }, + "requester_name": { + "type": "string", + "readOnly": true + }, + "requester_display": { + "type": "string", + "readOnly": true + }, + "requester_contact_email": { + "type": "string", + "format": "email", + "readOnly": true + }, + "requester_contact_phone": { + "type": "string", + "readOnly": true + }, + "requester_contact_verified": { + "type": "boolean", + "readOnly": true + }, + "status": { + "type": "string", + "readOnly": true + }, + "requested_for": { + "type": "string", + "readOnly": true + }, + "rejection_reason": { + "type": "string", + "readOnly": true + }, + "assigned_box_label": { + "type": "string", + "readOnly": true, + "nullable": true + }, + "accepted_by": { + "allOf": [ + { + "$ref": "#/components/schemas/AdminRequestActor" + } + ], + "readOnly": true, + "nullable": true + }, + "issued_by": { + "allOf": [ + { + "$ref": "#/components/schemas/AdminRequestActor" + } + ], + "readOnly": true, + "nullable": true + }, + "accepted_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "issued_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "return_due_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "return_reminder_sent_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "renewal_count": { + "type": "integer", + "readOnly": true + }, + "closed_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "issue_evidence_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "return_evidence_ids": { + "type": "array", + "items": {}, + "readOnly": true + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AdminRequestItem" + }, + "readOnly": true + } + }, + "required": [ + "accepted_at", + "accepted_by", + "assigned_box_label", + "closed_at", + "created_at", + "has_kit_checklist", + "id", + "issue_evidence_id", + "issued_at", + "issued_by", + "items", + "makerspace_id", + "rejection_reason", + "renewal_count", + "requested_for", + "requester_contact_email", + "requester_contact_phone", + "requester_contact_verified", + "requester_display", + "requester_name", + "requester_username", + "return_due_at", + "return_evidence_ids", + "return_reminder_sent_at", + "status", + "updated_at" + ] + }, + "AdminRequestActor": { + "type": "object", + "properties": { + "username": { + "type": "string", + "readOnly": true + }, + "role": { + "type": "string", + "readOnly": true + } + }, + "required": [ + "role", + "username" + ] + }, + "AdminRequestItem": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "product_id": { + "type": "integer", + "readOnly": true + }, + "product_name": { + "type": "string", + "readOnly": true + }, + "storage_location": { + "type": "string", + "readOnly": true + }, + "tracking_mode": { + "type": "string", + "readOnly": true + }, + "requires_asset_qr": { + "type": "boolean", + "readOnly": true + }, + "requested_quantity": { + "type": "integer", + "readOnly": true + }, + "accepted_quantity": { + "type": "integer", + "readOnly": true + }, + "issued_quantity": { + "type": "integer", + "readOnly": true + }, + "returned_quantity": { + "type": "integer", + "readOnly": true + }, + "damaged_quantity": { + "type": "integer", + "readOnly": true + }, + "missing_quantity": { + "type": "integer", + "readOnly": true + }, + "needs_fix_quantity": { + "type": "integer", + "readOnly": true + }, + "issued_assets": { + "type": "array", + "items": {}, + "readOnly": true + } + }, + "required": [ + "accepted_quantity", + "damaged_quantity", + "id", + "issued_assets", + "issued_quantity", + "missing_quantity", + "needs_fix_quantity", + "product_id", + "product_name", + "requested_quantity", + "requires_asset_qr", + "returned_quantity", + "storage_location", + "tracking_mode" + ] + }, + "AdmissionAnswers": { + "type": "object", + "properties": { + "application_answers": { + "nullable": true + } + } + }, + "AdmissionExport": { + "type": "object", + "properties": { + "requests": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AdmissionRequest" + } + } + }, + "required": [ + "requests" + ] + }, + "AdmissionPayment": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "currency": { + "type": "string" + } + }, + "required": [ + "amount", + "currency", + "id", + "status" + ] + }, + "AdmissionRequest": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "state": { + "$ref": "#/components/schemas/State9efEnum" + }, + "application_answers": { + "nullable": true, + "readOnly": true + }, + "vouches": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Vouch" + } + }, + "admission_payment": { + "allOf": [ + { + "$ref": "#/components/schemas/AdmissionPayment" + } + ], + "nullable": true + }, + "expires_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "required": [ + "admission_payment", + "application_answers", + "id", + "state", + "vouches" + ] + }, + "AdmissionSettings": { + "type": "object", + "properties": { + "configured": { + "type": "boolean", + "readOnly": true + }, + "join_mode": { + "$ref": "#/components/schemas/JoinModeEnum" + }, + "vouches_required": { + "type": "integer", + "maximum": 5, + "minimum": 1 + }, + "voucher_min_tenure_days": { + "type": "integer", + "maximum": 3650, + "minimum": 0 + }, + "voucher_pending_limit": { + "type": "integer", + "maximum": 100, + "minimum": 1 + }, + "vouch_expiry_days": { + "type": "integer", + "maximum": 365, + "minimum": 1 + }, + "vouch_replaces_approval": { + "type": "boolean" + }, + "payment_timing": { + "$ref": "#/components/schemas/PaymentTimingEnum" + }, + "application_schema": { + "nullable": true + }, + "default_plan": { + "type": "integer", + "nullable": true + }, + "joining_fee": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "joining_fee_currency": { + "type": "string", + "pattern": "^[A-Za-z]{3}$" + }, + "rejoin_dues_rule": { + "$ref": "#/components/schemas/RejoinDuesRuleEnum" + }, + "request_expiry_days": { + "type": "integer", + "maximum": 365, + "minimum": 1 + }, + "recipe": { + "allOf": [ + { + "$ref": "#/components/schemas/RecipeEnum" + } + ], + "writeOnly": true + } + }, + "required": [ + "configured" + ] + }, + "AdmissionVouchOutcome": { + "type": "object", + "description": "A voucher receives the outcome, never the applicant's private submission.", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "state": { + "$ref": "#/components/schemas/State9efEnum" + } + }, + "required": [ + "id", + "state" + ] + }, + "AgeBucketEnum": { + "enum": [ + "0-30", + "31-60", + "61-90", + "90+" + ], + "type": "string", + "description": "* `0-30` - 0-30\n* `31-60` - 31-60\n* `61-90` - 61-90\n* `90+` - 90+" + }, + "AlignEnum": { + "enum": [ + "left", + "center", + "right" + ], + "type": "string", + "description": "* `left` - left\n* `center` - center\n* `right` - right" + }, + "AllowedFormatsEnum": { + "enum": [ + "csv", + "xlsx", + "pdf" + ], + "type": "string", + "description": "* `csv` - csv\n* `xlsx` - xlsx\n* `pdf` - pdf" + }, + "AnalyticsMakerspace": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "slug" + ] + }, + "AnalyticsMakerspacePage": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "format": "uri", + "nullable": true + }, + "previous": { + "type": "string", + "format": "uri", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AnalyticsMakerspace" + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] + }, + "AnalyticsReportResponse": { + "oneOf": [ + { + "$ref": "#/components/schemas/AnalyticsSummary" + }, + { + "$ref": "#/components/schemas/TakenItemsReport" + }, + { + "$ref": "#/components/schemas/ActiveLoansReport" + }, + { + "$ref": "#/components/schemas/ReturnsReport" + }, + { + "$ref": "#/components/schemas/DamagedMissingReport" + }, + { + "$ref": "#/components/schemas/DamagedLostReport" + }, + { + "$ref": "#/components/schemas/QrScansReport" + }, + { + "$ref": "#/components/schemas/MostLentReport" + }, + { + "$ref": "#/components/schemas/TopBorrowersReport" + }, + { + "$ref": "#/components/schemas/RecentlyAddedReport" + }, + { + "$ref": "#/components/schemas/MachineUsageV2Report" + }, + { + "$ref": "#/components/schemas/EventAttendanceReport" + }, + { + "$ref": "#/components/schemas/BookingUtilizationReport" + }, + { + "$ref": "#/components/schemas/MaintenanceActivityV2Report" + }, + { + "$ref": "#/components/schemas/MachineUsageMembersReport" + }, + { + "$ref": "#/components/schemas/MachineUsageHeatmapReport" + }, + { + "$ref": "#/components/schemas/MachineDowntimeReport" + }, + { + "$ref": "#/components/schemas/MachineServiceV2Report" + }, + { + "$ref": "#/components/schemas/ServiceQueueTimesReport" + }, + { + "$ref": "#/components/schemas/PrinterJobsReport" + }, + { + "$ref": "#/components/schemas/MachineErrorsReport" + }, + { + "$ref": "#/components/schemas/WarrantyExpiryReport" + }, + { + "$ref": "#/components/schemas/BookingUtilizationV2Report" + }, + { + "$ref": "#/components/schemas/BookingOccupancyReport" + }, + { + "$ref": "#/components/schemas/BookingLeadTimeReport" + }, + { + "$ref": "#/components/schemas/BookingTopMembersReport" + }, + { + "$ref": "#/components/schemas/EventSignupsReport" + }, + { + "$ref": "#/components/schemas/EventRevenueReport" + }, + { + "$ref": "#/components/schemas/EventFunnelReport" + }, + { + "$ref": "#/components/schemas/EventCheckinTimesReport" + }, + { + "$ref": "#/components/schemas/EventFeedbackScoresReport" + }, + { + "$ref": "#/components/schemas/MemberActivityReport" + }, + { + "$ref": "#/components/schemas/FabLabHealthReport" + }, + { + "$ref": "#/components/schemas/PaymentReconciliationReport" + }, + { + "$ref": "#/components/schemas/PaymentCashBook" + }, + { + "$ref": "#/components/schemas/PaymentsLedgerReport" + }, + { + "$ref": "#/components/schemas/RevenueOverTimeReport" + }, + { + "$ref": "#/components/schemas/CashBookReport" + }, + { + "$ref": "#/components/schemas/AccountingJournalReport" + }, + { + "$ref": "#/components/schemas/GenericAnalyticsReport" + }, + { + "$ref": "#/components/schemas/CommunicationsHealthReport" + }, + { + "$ref": "#/components/schemas/PlatformServicesReport" + }, + { + "$ref": "#/components/schemas/MemberRetentionReport" + }, + { + "$ref": "#/components/schemas/MembershipRevenueReport" + }, + { + "$ref": "#/components/schemas/MembershipGrowthReport" + }, + { + "$ref": "#/components/schemas/PaymentsAgeingReport" + }, + { + "$ref": "#/components/schemas/EventRegistrationReport" + }, + { + "$ref": "#/components/schemas/EventFeedbackReport" + }, + { + "$ref": "#/components/schemas/BookingResourceUtilizationReport" + }, + { + "$ref": "#/components/schemas/BookingReservationReport" + }, + { + "$ref": "#/components/schemas/BookingAnswerReport" + } + ] + }, + "AnalyticsSummary": { + "type": "object", + "properties": { + "products": { + "type": "integer" + }, + "assets": { + "type": "integer" + }, + "active_loans": { + "type": "integer" + }, + "available_quantity": { + "type": "integer" + }, + "issued_quantity": { + "type": "integer" + }, + "damaged_quantity": { + "type": "integer" + }, + "missing_quantity": { + "type": "integer" + }, + "asset_available": { + "type": "integer" + }, + "asset_reserved": { + "type": "integer" + }, + "asset_issued": { + "type": "integer" + }, + "asset_damaged": { + "type": "integer" + }, + "asset_lost": { + "type": "integer" + }, + "asset_maintenance": { + "type": "integer" + }, + "asset_retired": { + "type": "integer" + } + }, + "required": [ + "active_loans", + "asset_available", + "asset_damaged", + "asset_issued", + "asset_lost", + "asset_maintenance", + "asset_reserved", + "asset_retired", + "assets", + "available_quantity", + "damaged_quantity", + "issued_quantity", + "missing_quantity", + "products" + ] + }, + "AnchorEnum": { + "enum": [ + "completion", + "calendar" + ], + "type": "string", + "description": "* `completion` - Completion date\n* `calendar` - Fixed calendar" + }, + "AnchorStatusEnum": { + "enum": [ + "pending", + "not_configured", + "externally_anchored", + "invalid", + "unavailable" + ], + "type": "string", + "description": "* `pending` - Pending\n* `not_configured` - Not configured\n* `externally_anchored` - Externally anchored\n* `invalid` - Invalid\n* `unavailable` - Unavailable" + }, + "ApiClient": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "label": { + "type": "string", + "maxLength": 200 + }, + "client_id": { + "type": "string", + "readOnly": true + }, + "client_type": { + "$ref": "#/components/schemas/ClientTypeEnum" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "rate_limit_tier": { + "$ref": "#/components/schemas/RateLimitTierEnum" + }, + "makerspace": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "public_makerspace_code": { + "type": "string", + "readOnly": true + }, + "allowed_origins": { + "type": "array", + "items": { + "type": "string" + } + }, + "backend_base_url": { + "type": "string", + "readOnly": true + }, + "public_api_base_url": { + "type": "string", + "readOnly": true + }, + "is_active": { + "type": "boolean" + }, + "last_seen_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "last_seen_ip": { + "type": "string", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "allowed_origins", + "backend_base_url", + "client_id", + "created_at", + "id", + "label", + "last_seen_at", + "last_seen_ip", + "makerspace", + "public_api_base_url", + "public_makerspace_code", + "scopes", + "updated_at" + ] + }, + "ApiClientCreateResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "label": { + "type": "string", + "maxLength": 200 + }, + "client_id": { + "type": "string", + "readOnly": true + }, + "client_secret": { + "type": "string", + "readOnly": true + }, + "client_type": { + "$ref": "#/components/schemas/ClientTypeEnum" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "rate_limit_tier": { + "$ref": "#/components/schemas/RateLimitTierEnum" + }, + "makerspace": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "public_makerspace_code": { + "type": "string", + "readOnly": true + }, + "allowed_origins": { + "type": "array", + "items": { + "type": "string" + } + }, + "backend_base_url": { + "type": "string", + "readOnly": true + }, + "public_api_base_url": { + "type": "string", + "readOnly": true + }, + "is_active": { + "type": "boolean" + }, + "last_seen_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "last_seen_ip": { + "type": "string", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "allowed_origins", + "backend_base_url", + "client_id", + "client_secret", + "created_at", + "id", + "label", + "last_seen_at", + "last_seen_ip", + "makerspace", + "public_api_base_url", + "public_makerspace_code", + "scopes", + "updated_at" + ] + }, + "ApiClientScopeCatalogResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiClientScopeOption" + } + } + }, + "required": [ + "count", + "results" + ] + }, + "ApiClientScopeOption": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "group": { + "type": "string" + }, + "grantable": { + "type": "boolean" + }, + "lock_reason": { + "type": "string", + "nullable": true + } + }, + "required": [ + "description", + "grantable", + "group", + "label", + "lock_reason", + "value" + ] + }, + "ApiIntegrationSettings": { + "type": "object", + "properties": { + "public_code": { + "type": "string", + "readOnly": true + }, + "public_api_key": { + "type": "string", + "readOnly": true + }, + "cors_allowed_origins": { + "type": "array", + "items": { + "type": "string" + }, + "readOnly": true + }, + "telegram_group_chat_id": { + "type": "string", + "maxLength": 64 + }, + "telegram_bot_token": { + "type": "string", + "writeOnly": true + }, + "telegram_bot_token_set": { + "type": "boolean", + "readOnly": true + }, + "smtp_host": { + "type": "string", + "maxLength": 200 + }, + "smtp_port": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "smtp_username": { + "type": "string", + "maxLength": 200 + }, + "smtp_password": { + "type": "string", + "writeOnly": true + }, + "smtp_password_set": { + "type": "boolean", + "readOnly": true + }, + "smtp_use_tls": { + "type": "boolean" + }, + "smtp_use_ssl": { + "type": "boolean" + }, + "smtp_from_email": { + "oneOf": [ + { + "type": "string", + "format": "email", + "maxLength": 254 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "slack_webhook_url": { + "type": "string", + "writeOnly": true, + "maxLength": 2048 + }, + "slack_webhook_url_set": { + "type": "boolean", + "readOnly": true + }, + "mattermost_webhook_url": { + "type": "string", + "writeOnly": true, + "maxLength": 2048 + }, + "mattermost_webhook_url_set": { + "type": "boolean", + "readOnly": true + }, + "discord_webhook_url": { + "type": "string", + "writeOnly": true, + "maxLength": 2048 + }, + "discord_webhook_url_set": { + "type": "boolean", + "readOnly": true + }, + "default_loan_days": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + } + }, + "required": [ + "cors_allowed_origins", + "discord_webhook_url_set", + "mattermost_webhook_url_set", + "public_api_key", + "public_code", + "slack_webhook_url_set", + "smtp_password_set", + "telegram_bot_token_set" + ] + }, + "ApiKeyRequest": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "makerspace": { + "type": "integer" + }, + "label": { + "type": "string", + "maxLength": 120 + }, + "reason": { + "type": "string" + }, + "allowed_origins": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusE94Enum" + } + ], + "readOnly": true + }, + "resolution_note": { + "type": "string", + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "resolved_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "allowed_origins", + "created_at", + "id", + "label", + "makerspace", + "resolution_note", + "resolved_at", + "status" + ] + }, + "ApprovalModeE35Enum": { + "enum": [ + "instant", + "approve" + ], + "type": "string", + "description": "* `instant` - Instant confirmation\n* `approve` - Staff approval required" + }, + "ApproveRequest": { + "type": "object", + "properties": { + "role_id": { + "type": "integer" + }, + "plan_id": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "role_id" + ] + }, + "ArchiveCustodyReadiness": { + "type": "object", + "properties": { + "below_floor_makerspaces": { + "type": "integer", + "minimum": 0 + }, + "zero_recipient_makerspaces": { + "type": "integer", + "minimum": 0 + }, + "undelivered_alarms": { + "type": "integer", + "minimum": 0 + }, + "alarms_with_no_operator_address": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "alarms_with_no_operator_address", + "below_floor_makerspaces", + "undelivered_alarms", + "zero_recipient_makerspaces" + ] + }, + "ArchiveCustodyStateEnum": { + "type": "string", + "enum": [ + "healthy", + "not_applicable", + "degraded_one_recipient", + "floor_breached_zero" + ] + }, + "ArchiveRecipient": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "public_recipient": { + "type": "string", + "readOnly": true + }, + "fingerprint": { + "type": "string", + "readOnly": true + }, + "label": { + "type": "string", + "readOnly": true + }, + "added_by": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "added_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "compromised_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "verified_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "challenge_issued_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "added_at", + "added_by", + "challenge_issued_at", + "compromised_at", + "fingerprint", + "id", + "label", + "public_recipient", + "revoked_at", + "verified_at" + ] + }, + "ArchiveRecipientChallenge": { + "type": "object", + "properties": { + "recipient": { + "$ref": "#/components/schemas/ArchiveRecipient" + }, + "encrypted_challenge": { + "type": "string", + "description": "The binary age ciphertext encoded as unpadded base64url for JSON transport. Its decrypted plaintext is the 32-byte nonce encoded as canonical, unpadded base64url." + }, + "nonce_encoding": { + "type": "string", + "default": "base64url-unpadded", + "description": "The decrypted nonce uses canonical unpadded base64url." + } + }, + "required": [ + "encrypted_challenge", + "recipient" + ] + }, + "ArchiveRecipientCreate": { + "type": "object", + "properties": { + "public_recipient": { + "type": "string", + "maxLength": 200 + }, + "label": { + "type": "string", + "maxLength": 120 + } + }, + "required": [ + "label", + "public_recipient" + ] + }, + "ArchiveRecipientError": { + "type": "object", + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "code", + "detail" + ] + }, + "ArchiveRecipientVerify": { + "type": "object", + "properties": { + "nonce": { + "type": "string", + "description": "The decrypted 32-byte nonce in canonical, unpadded base64url form. Padding and non-canonical encodings are refused.", + "maxLength": 128 + } + }, + "required": [ + "nonce" + ] + }, + "ArchiveRequestError": { + "type": "object", + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "detail" + ] + }, + "ArchiveRequestValidationError": { + "type": "object", + "description": "DRF field-keyed errors, which are NOT the `detail`/`code` shape.\n\nA blank or overlong reason fails in `serializer.is_valid(raise_exception=True)` and comes\nback as `{\"reason\": [\"...\"]}`. Declaring every 400 as `ArchiveRequestError` published a\ncontract the endpoint does not honour, so a generated client would destructure `detail`\nand find nothing. Follows the `ProvisionSubdomainValidationErrorSerializer` precedent.", + "properties": { + "reason": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ArchivedPaymentMakerspace": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "slug": { + "type": "string", + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "slug" + ] + }, + "ArchivedPaymentSummary": { + "type": "object", + "properties": { + "makerspace": { + "$ref": "#/components/schemas/ArchivedPaymentMakerspace" + }, + "pending_count": { + "type": "integer", + "minimum": 0 + }, + "total_count": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "makerspace", + "pending_count", + "total_count" + ] + }, + "AssetChainGroup": { + "type": "object", + "properties": { + "asset_id": { + "type": "integer", + "nullable": true + }, + "asset_tag": { + "type": "string" + }, + "serial_number": { + "type": "string" + }, + "status": { + "type": "string" + }, + "events": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TimelineEvent" + } + } + }, + "required": [ + "asset_id", + "asset_tag", + "events", + "serial_number", + "status" + ] + }, + "AssetGenerate": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "maximum": 200, + "minimum": 1 + }, + "name_prefix": { + "type": "string" + }, + "serial_numbers": { + "type": "array", + "items": { + "type": "string" + } + }, + "print_batch_id": { + "type": "integer", + "nullable": true + }, + "create_print_batch": { + "type": "boolean", + "default": false + } + }, + "required": [ + "count" + ] + }, + "AssetGenerateItem": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "asset_tag": { + "type": "string" + }, + "qr": { + "$ref": "#/components/schemas/QrCode" + } + }, + "required": [ + "asset_tag", + "id", + "qr" + ] + }, + "AssetGenerateResult": { + "type": "object", + "properties": { + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssetGenerateItem" + } + }, + "print_batch_id": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "assets", + "print_batch_id" + ] + }, + "AssetQrHistory": { + "type": "object", + "properties": { + "asset": { + "type": "integer" + }, + "scans": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QrHistoryItem" + } + } + }, + "required": [ + "asset", + "scans" + ] + }, + "AssignBox": { + "type": "object", + "properties": { + "box_code": { + "type": "string" + } + }, + "required": [ + "box_code" + ] + }, + "AssignOperator": { + "type": "object", + "properties": { + "user_id": { + "type": "integer" + }, + "access_level": { + "type": "string", + "maxLength": 16 + } + }, + "required": [ + "access_level", + "user_id" + ] + }, + "AssignableMachineType": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "is_global": { + "type": "boolean" + } + }, + "required": [ + "id", + "is_global", + "name", + "slug" + ] + }, + "AttendanceCorrectionResponse": { + "type": "object", + "properties": { + "registration_id": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "revoked_certificates": { + "type": "integer" + } + }, + "required": [ + "registration_id", + "revoked_certificates", + "status" + ] + }, + "AttendedEvent": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "starts_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "starts_at", + "title" + ] + }, + "Audience356Enum": { + "enum": [ + "member", + "staff" + ], + "type": "string", + "description": "* `member` - Member\n* `staff` - Staff" + }, + "Audience817Enum": { + "enum": [ + "member", + "staff" + ], + "type": "string", + "description": "* `member` - member\n* `staff` - staff" + }, + "AudienceBe5Enum": { + "enum": [ + "member", + "public" + ], + "type": "string", + "description": "* `member` - Active members\n* `public` - All accounts" + }, + "AuditLog": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "actor": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "action": { + "type": "string", + "readOnly": true + }, + "makerspace": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "target_type": { + "type": "string", + "readOnly": true + }, + "target_id": { + "type": "string", + "readOnly": true + }, + "meta": { + "type": "object", + "additionalProperties": {}, + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "action_label": { + "type": "string", + "readOnly": true + }, + "actor_display": { + "type": "string", + "nullable": true, + "readOnly": true + }, + "meta_redacted": { + "type": "boolean", + "readOnly": true + }, + "target_link": { + "allOf": [ + { + "$ref": "#/components/schemas/AuditTargetLink" + } + ], + "nullable": true, + "readOnly": true + } + }, + "required": [ + "action", + "action_label", + "actor", + "actor_display", + "created_at", + "id", + "makerspace", + "meta", + "meta_redacted", + "target_id", + "target_link", + "target_type" + ] + }, + "AuditTargetLink": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "label", + "path" + ] + }, + "AuditVerificationBadRequest": { + "oneOf": [ + { + "$ref": "#/components/schemas/AuditVerificationError" + }, + { + "$ref": "#/components/schemas/AuditVerificationFilterError" + } + ] + }, + "AuditVerificationDetailError": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + }, + "required": [ + "detail" + ] + }, + "AuditVerificationError": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + } + }, + "required": [ + "code", + "detail" + ] + }, + "AuditVerificationFilterError": { + "type": "object", + "properties": { + "action": { + "type": "array", + "items": { + "type": "string" + } + }, + "target_type": { + "type": "array", + "items": { + "type": "string" + } + }, + "target_id": { + "type": "array", + "items": { + "type": "string" + } + }, + "actor": { + "type": "array", + "items": { + "type": "string" + } + }, + "start": { + "type": "array", + "items": { + "type": "string" + } + }, + "end": { + "type": "array", + "items": { + "type": "string" + } + }, + "non_field_errors": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "AuthMembership": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "slug": { + "type": "string" + }, + "role": { + "type": "string", + "nullable": true + }, + "role_id": { + "type": "integer", + "nullable": true + }, + "role_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "role_slugs": { + "type": "array", + "items": { + "type": "string" + } + }, + "role_names": { + "type": "array", + "items": { + "type": "string" + } + }, + "role_name": { + "type": "string" + }, + "role_slug": { + "type": "string", + "nullable": true + }, + "source": { + "$ref": "#/components/schemas/AuthMembershipSourceEnum" + }, + "actions": { + "type": "array", + "items": { + "type": "string" + } + }, + "can_configure_machine_types": { + "type": "boolean" + }, + "is_machine_only": { + "type": "boolean" + }, + "can_refer": { + "type": "boolean" + }, + "can_verify": { + "type": "boolean" + }, + "verified_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "referrals_enabled": { + "type": "boolean" + } + }, + "required": [ + "actions", + "can_configure_machine_types", + "can_refer", + "can_verify", + "id", + "is_machine_only", + "referrals_enabled", + "role", + "role_id", + "role_ids", + "role_name", + "role_names", + "role_slug", + "role_slugs", + "slug", + "source", + "verified_at" + ] + }, + "AuthMembershipSourceEnum": { + "enum": [ + "membership", + "organization" + ], + "type": "string", + "description": "* `membership` - membership\n* `organization` - organization" + }, + "AuthUserPayload": { + "type": "object", + "properties": { + "session_authority": { + "type": "string", + "description": "Effective session surface: member, native_staff, staff, staff_api or verification_only. Native staff APIs require X-Makerspace-Id selecting an active local membership; URL and body targets must match." + }, + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + }, + "display_name": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "role": { + "type": "string" + }, + "is_superuser": { + "type": "boolean" + }, + "must_change_password": { + "type": "boolean" + }, + "makerspaces": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuthMembership" + } + }, + "event_grants": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GrantedEventEntry" + } + }, + "booking_grants": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GrantedBookingEntry" + } + } + }, + "required": [ + "display_name", + "email", + "email_verified", + "id", + "is_superuser", + "makerspaces", + "must_change_password", + "phone", + "role", + "session_authority", + "username" + ] + }, + "AvailabilityEnum": { + "type": "string", + "enum": [ + "Available", + "Limited", + "Full" + ] + }, + "BackupArchive": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "readOnly": true + }, + "scope": { + "allOf": [ + { + "$ref": "#/components/schemas/ScopeEnum" + } + ], + "readOnly": true + }, + "makerspace": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/BackupArchiveStatusEnum" + } + ], + "readOnly": true + }, + "manifest": { + "readOnly": true + }, + "size_bytes": { + "type": "integer", + "readOnly": true + }, + "age_encrypted": { + "type": "boolean", + "readOnly": true + }, + "failure_detail": { + "type": "string", + "readOnly": true + }, + "started_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "completed_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "expires_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "purge_warning": { + "type": "string", + "readOnly": true + } + }, + "required": [ + "age_encrypted", + "completed_at", + "created_at", + "expires_at", + "failure_detail", + "id", + "makerspace", + "manifest", + "purge_warning", + "scope", + "size_bytes", + "started_at", + "status" + ] + }, + "BackupArchiveStatusEnum": { + "enum": [ + "pending", + "running", + "promoting", + "available", + "failed", + "expired" + ], + "type": "string", + "description": "* `pending` - Pending\n* `running` - Running\n* `promoting` - Promoting\n* `available` - Available\n* `failed` - Failed\n* `expired` - Expired" + }, + "BackupCustody": { + "type": "object", + "properties": { + "superadmin_access_enabled": { + "type": "boolean" + }, + "verified_recipient_count": { + "type": "integer" + }, + "archive_custody": { + "allOf": [ + { + "$ref": "#/components/schemas/CustodyState" + } + ], + "nullable": true + }, + "tenant_exit_custody": { + "allOf": [ + { + "$ref": "#/components/schemas/CustodyState" + } + ], + "nullable": true + } + }, + "required": [ + "archive_custody", + "superadmin_access_enabled", + "tenant_exit_custody", + "verified_recipient_count" + ] + }, + "BackupDestination": { + "type": "object", + "description": "Credentials are write-only; reads report only whether they are stored.", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "name": { + "type": "string", + "maxLength": 64, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "kind": { + "allOf": [ + { + "$ref": "#/components/schemas/BackupDestinationKindEnum" + } + ], + "readOnly": true + }, + "endpoint_url": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "maxLength": 300 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "bucket": { + "type": "string", + "maxLength": 255 + }, + "region": { + "type": "string", + "maxLength": 64 + }, + "prefix": { + "type": "string", + "maxLength": 200 + }, + "addressing_style": { + "$ref": "#/components/schemas/AddressingStyleEnum" + }, + "access_key_id": { + "type": "string", + "writeOnly": true, + "maxLength": 200 + }, + "secret_access_key": { + "type": "string", + "writeOnly": true, + "maxLength": 200 + }, + "credentials_set": { + "type": "boolean", + "readOnly": true + }, + "enabled": { + "type": "boolean" + }, + "retention_days": { + "type": "integer", + "maximum": 3650, + "minimum": 1 + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "retired_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "last_success_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "last_error_code": { + "type": "string", + "readOnly": true + } + }, + "required": [ + "bucket", + "created_at", + "credentials_set", + "id", + "kind", + "last_error_code", + "last_success_at", + "name", + "retired_at", + "updated_at" + ] + }, + "BackupDestinationKindEnum": { + "enum": [ + "s3" + ], + "type": "string", + "description": "* `s3` - S3-compatible (AWS, R2, Supabase Storage, MinIO)" + }, + "BackupDestinationPage": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "format": "uri", + "nullable": true + }, + "previous": { + "type": "string", + "format": "uri", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BackupDestination" + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] + }, + "BackupDestinationUpload": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "destination": { + "type": "string", + "readOnly": true + }, + "archive_id_snapshot": { + "type": "string", + "format": "uuid", + "readOnly": true + }, + "scope_snapshot": { + "type": "string", + "readOnly": true + }, + "makerspace_id_snapshot": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "archive_completed_at_snapshot": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "expected_size_bytes": { + "type": "integer", + "readOnly": true + }, + "expected_sha256": { + "type": "string", + "readOnly": true + }, + "integrity_source": { + "allOf": [ + { + "$ref": "#/components/schemas/IntegritySourceEnum" + } + ], + "readOnly": true + }, + "remote_locator": { + "type": "string", + "readOnly": true + }, + "state": { + "allOf": [ + { + "$ref": "#/components/schemas/BackupDestinationUploadStateEnum" + } + ], + "readOnly": true + }, + "attempts": { + "type": "integer", + "readOnly": true + }, + "next_attempt_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "uploaded_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "verified_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "expires_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "deleted_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "last_error_code": { + "type": "string", + "readOnly": true + } + }, + "required": [ + "archive_completed_at_snapshot", + "archive_id_snapshot", + "attempts", + "deleted_at", + "destination", + "expected_sha256", + "expected_size_bytes", + "expires_at", + "id", + "integrity_source", + "last_error_code", + "makerspace_id_snapshot", + "next_attempt_at", + "remote_locator", + "scope_snapshot", + "state", + "uploaded_at", + "verified_at" + ] + }, + "BackupDestinationUploadPage": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "format": "uri", + "nullable": true + }, + "previous": { + "type": "string", + "format": "uri", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BackupDestinationUpload" + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] + }, + "BackupDestinationUploadStateEnum": { + "enum": [ + "pending", + "uploading", + "bytes_verified", + "abandoned", + "deleted" + ], + "type": "string", + "description": "* `pending` - Pending upload\n* `uploading` - Uploading\n* `bytes_verified` - Bytes verified\n* `abandoned` - Abandoned (source no longer available)\n* `deleted` - Deleted by retention" + }, + "BackupDownload": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "purge_warning": { + "type": "string" + } + }, + "required": [ + "expires_at", + "purge_warning", + "url" + ] + }, + "BadgePdfRequest": { + "type": "object", + "properties": { + "registration_ids": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "maxItems": 200 + }, + "template_override": { + "allOf": [ + { + "$ref": "#/components/schemas/BadgeTemplate" + } + ], + "nullable": true + }, + "include_attended": { + "type": "boolean", + "default": false + } + }, + "required": [ + "registration_ids" + ] + }, + "BadgeTemplate": { + "type": "object", + "properties": { + "version": { + "type": "integer" + }, + "paper_size": { + "$ref": "#/components/schemas/PaperSizeEnum" + }, + "orientation": { + "$ref": "#/components/schemas/OrientationEnum" + }, + "page_width_mm": { + "type": "number", + "format": "double", + "nullable": true + }, + "page_height_mm": { + "type": "number", + "format": "double", + "nullable": true + }, + "card_width_mm": { + "type": "number", + "format": "double" + }, + "card_height_mm": { + "type": "number", + "format": "double" + }, + "margin_mm": { + "type": "number", + "format": "double" + }, + "gap_mm": { + "type": "number", + "format": "double" + }, + "template": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "type": "string", + "maxLength": 80 + } + }, + "font_size_pt": { + "type": "number", + "format": "double" + }, + "name_font_size_pt": { + "type": "number", + "format": "double" + }, + "text_align": { + "$ref": "#/components/schemas/TextAlignEnum" + }, + "include_qr": { + "type": "boolean" + }, + "grid_mm": { + "type": "number", + "format": "double", + "maximum": 10, + "minimum": 0.5 + }, + "elements": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PositionedElement" + } + } + } + }, + "BillingPolicyError": { + "oneOf": [ + { + "$ref": "#/components/schemas/HardwareRequestError" + }, + { + "$ref": "#/components/schemas/BillingPolicyValidationError" + } + ] + }, + "BillingPolicyValidationError": { + "type": "object", + "properties": { + "number_prefix": { + "type": "array", + "items": { + "type": "string" + } + }, + "legal_name": { + "type": "array", + "items": { + "type": "string" + } + }, + "address": { + "type": "array", + "items": { + "type": "string" + } + }, + "tax_id": { + "type": "array", + "items": { + "type": "string" + } + }, + "footer": { + "type": "array", + "items": { + "type": "string" + } + }, + "account_codes": {}, + "payment_terms_days": { + "type": "array", + "items": { + "type": "string" + } + }, + "reminder_days_before": {}, + "reminder_days_after": {}, + "max_reminders": { + "type": "array", + "items": { + "type": "string" + } + }, + "non_field_errors": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "BillingSettings": { + "type": "object", + "properties": { + "payment_terms_days": { + "type": "integer", + "maximum": 365, + "minimum": 0 + }, + "reminder_days_before": { + "type": "array", + "items": { + "type": "integer", + "maximum": 365, + "minimum": 1 + }, + "maxItems": 20 + }, + "reminder_days_after": { + "type": "array", + "items": { + "type": "integer", + "maximum": 365, + "minimum": 1 + }, + "maxItems": 20 + }, + "max_reminders": { + "type": "integer", + "maximum": 20, + "minimum": 0 + }, + "number_prefix": { + "type": "string", + "maxLength": 32 + }, + "legal_name": { + "type": "string", + "maxLength": 150 + }, + "address": { + "type": "string", + "maxLength": 1000 + }, + "tax_id": { + "type": "string", + "maxLength": 64 + }, + "footer": { + "type": "string", + "maxLength": 1000 + }, + "account_codes": {} + }, + "required": [ + "max_reminders", + "reminder_days_after", + "reminder_days_before" + ] + }, + "Binding": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "readOnly": true + }, + "makerspace": { + "type": "integer", + "readOnly": true + }, + "device": { + "type": "string", + "format": "uuid", + "readOnly": true + }, + "machine": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "enabled": { + "type": "boolean", + "readOnly": true + }, + "auto_apply_terminal": { + "type": "boolean", + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "auto_apply_terminal", + "created_at", + "device", + "enabled", + "id", + "machine", + "makerspace", + "updated_at" + ] + }, + "BindingCreate": { + "type": "object", + "properties": { + "device_id": { + "type": "string", + "format": "uuid" + }, + "machine_id": { + "type": "integer", + "minimum": 1 + }, + "enabled": { + "type": "boolean", + "default": true + }, + "auto_apply_terminal": { + "type": "boolean", + "default": false + } + }, + "required": [ + "device_id", + "machine_id" + ] + }, + "BlankEnum": { + "enum": [ + "" + ] + }, + "BookableSpaceAdmin": { + "type": "object", + "properties": { + "clone_enabled": { + "type": "boolean", + "readOnly": true + }, + "staff_tools_enabled": { + "type": "boolean", + "readOnly": true + }, + "can_copy_booking_rules": { + "type": "boolean", + "readOnly": true + }, + "recurring_enabled": { + "type": "boolean", + "readOnly": true + }, + "staff_grants_enabled": { + "type": "boolean", + "readOnly": true + }, + "broadcasts_enabled": { + "type": "boolean", + "readOnly": true + }, + "staff_permissions": { + "type": "array", + "items": { + "type": "string" + }, + "readOnly": true + }, + "can_manage_grants": { + "type": "boolean", + "readOnly": true + }, + "report_features": { + "type": "array", + "items": { + "type": "string" + }, + "readOnly": true + }, + "checkin_enabled": { + "type": "boolean", + "readOnly": true + }, + "passes_enabled": { + "type": "boolean", + "readOnly": true + }, + "website_enabled": { + "type": "boolean", + "readOnly": true + }, + "embed_enabled": { + "type": "boolean", + "readOnly": true + }, + "id": { + "type": "integer", + "readOnly": true + }, + "public_token": { + "type": "string", + "format": "uuid", + "readOnly": true + }, + "makerspace_id": { + "type": "integer", + "readOnly": true + }, + "name": { + "type": "string", + "readOnly": true + }, + "kind": { + "allOf": [ + { + "$ref": "#/components/schemas/Kind3bfEnum" + } + ], + "readOnly": true + }, + "description": { + "type": "string", + "readOnly": true + }, + "capacity": { + "type": "integer", + "readOnly": true + }, + "location": { + "type": "string", + "readOnly": true + }, + "image_url": { + "type": "string", + "readOnly": true + }, + "is_public": { + "type": "boolean", + "readOnly": true + }, + "show_public_availability": { + "type": "boolean", + "readOnly": true + }, + "show_public_booker_names": { + "type": "boolean", + "readOnly": true + }, + "approval_mode": { + "allOf": [ + { + "$ref": "#/components/schemas/ApprovalModeE35Enum" + } + ], + "readOnly": true + }, + "min_booking_duration_minutes": { + "type": "integer", + "readOnly": true + }, + "max_booking_duration_minutes": { + "type": "integer", + "readOnly": true + }, + "booking_lead_time_minutes": { + "type": "integer", + "readOnly": true + }, + "max_booking_advance_days": { + "type": "integer", + "readOnly": true + }, + "buffer_before_minutes": { + "type": "integer", + "readOnly": true + }, + "buffer_after_minutes": { + "type": "integer", + "readOnly": true + }, + "cancellation_cutoff_hours": { + "type": "integer", + "readOnly": true + }, + "custom_form": { + "readOnly": true, + "nullable": true + }, + "requester_notifications_enabled": { + "type": "boolean", + "readOnly": true, + "nullable": true + }, + "payment_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true + }, + "machine_type": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "effective_requester_notifications_enabled": { + "type": "boolean", + "readOnly": true + }, + "is_active": { + "type": "boolean", + "readOnly": true + }, + "created_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "approval_mode", + "booking_lead_time_minutes", + "broadcasts_enabled", + "buffer_after_minutes", + "buffer_before_minutes", + "can_copy_booking_rules", + "can_manage_grants", + "cancellation_cutoff_hours", + "capacity", + "checkin_enabled", + "clone_enabled", + "created_at", + "created_by_id", + "custom_form", + "description", + "effective_requester_notifications_enabled", + "embed_enabled", + "id", + "image_url", + "is_active", + "is_public", + "kind", + "location", + "machine_type", + "makerspace_id", + "max_booking_advance_days", + "max_booking_duration_minutes", + "min_booking_duration_minutes", + "name", + "passes_enabled", + "payment_amount", + "public_token", + "recurring_enabled", + "report_features", + "requester_notifications_enabled", + "show_public_availability", + "show_public_booker_names", + "staff_grants_enabled", + "staff_permissions", + "staff_tools_enabled", + "updated_at", + "website_enabled" + ] + }, + "BookableSpaceBookingRules": { + "type": "object", + "properties": { + "min_booking_duration_minutes": { + "type": "integer", + "maximum": 2147483647, + "minimum": 1 + }, + "max_booking_duration_minutes": { + "type": "integer", + "maximum": 2147483647, + "minimum": 1 + }, + "booking_lead_time_minutes": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "max_booking_advance_days": { + "type": "integer", + "maximum": 2147483647, + "minimum": 1 + }, + "buffer_before_minutes": { + "type": "integer", + "maximum": 1440, + "minimum": 0 + }, + "buffer_after_minutes": { + "type": "integer", + "maximum": 1440, + "minimum": 0 + }, + "cancellation_cutoff_hours": { + "type": "integer", + "maximum": 8760, + "minimum": 0 + }, + "approval_mode": { + "$ref": "#/components/schemas/ApprovalModeE35Enum" + } + } + }, + "BookableSpaceListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookableSpaceAdmin" + } + } + }, + "required": [ + "count", + "results" + ] + }, + "BookableSpaceWrite": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 200 + }, + "kind": { + "allOf": [ + { + "$ref": "#/components/schemas/Kind3bfEnum" + } + ], + "default": "other" + }, + "description": { + "type": "string", + "default": "" + }, + "capacity": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "location": { + "type": "string", + "default": "", + "maxLength": 255 + }, + "is_public": { + "type": "boolean", + "default": false + }, + "show_public_availability": { + "type": "boolean", + "default": false + }, + "show_public_booker_names": { + "type": "boolean", + "default": false + }, + "custom_form": { + "nullable": true + }, + "requester_notifications_enabled": { + "type": "boolean", + "nullable": true + }, + "payment_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "default": "0.00" + }, + "machine_type": { + "type": "integer", + "minimum": 1, + "nullable": true + } + }, + "required": [ + "name" + ] + }, + "BookingAdmin": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "public_token": { + "type": "string", + "format": "uuid", + "readOnly": true + }, + "space_id": { + "type": "integer", + "readOnly": true + }, + "name": { + "type": "string", + "readOnly": true + }, + "email": { + "type": "string", + "readOnly": true + }, + "phone": { + "type": "string", + "readOnly": true + }, + "starts_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "ends_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/Status2caEnum" + } + ], + "readOnly": true + }, + "arrived_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "note": { + "type": "string", + "readOnly": true + }, + "custom_answers": { + "readOnly": true, + "nullable": true + }, + "cancelled_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "status_before_cancel": { + "type": "string", + "readOnly": true + }, + "restore_until": { + "type": "string", + "format": "date-time", + "nullable": true, + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "payment": { + "allOf": [ + { + "$ref": "#/components/schemas/StaffPaymentSummary" + } + ], + "nullable": true, + "readOnly": true + } + }, + "required": [ + "arrived_at", + "cancelled_at", + "created_at", + "custom_answers", + "email", + "ends_at", + "id", + "name", + "note", + "payment", + "phone", + "public_token", + "restore_until", + "space_id", + "starts_at", + "status", + "status_before_cancel" + ] + }, + "BookingAnswerReport": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "report_key": { + "type": "string" + }, + "meta": { + "$ref": "#/components/schemas/BookingReportMeta" + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingAnswerReportRow" + } + } + }, + "required": [ + "meta", + "report_key", + "rows", + "typed_rows" + ] + }, + "BookingAnswerReportRow": { + "type": "object", + "properties": { + "booking_id": { + "type": "integer" + }, + "space_id": { + "type": "integer" + }, + "starts_at": { + "type": "string", + "format": "date-time" + }, + "ends_at": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string" + }, + "answers": { + "type": "string", + "description": "JSON of the stored custom-answer snapshot; null when absent." + } + }, + "required": [ + "answers", + "booking_id", + "ends_at", + "space_id", + "starts_at", + "status" + ] + }, + "BookingArrival": { + "type": "object", + "properties": { + "booking_id": { + "type": "integer" + }, + "display_name": { + "type": "string" + }, + "space_name": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "starts_at": { + "type": "string", + "format": "date-time" + }, + "ends_at": { + "type": "string", + "format": "date-time" + }, + "status": { + "$ref": "#/components/schemas/Status2caEnum" + }, + "arrived_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "can_arrive": { + "type": "boolean" + }, + "can_reverse": { + "type": "boolean" + }, + "can_print_pass": { + "type": "boolean", + "readOnly": true + }, + "waiver_state": { + "$ref": "#/components/schemas/WaiverStateEnum" + } + }, + "required": [ + "arrived_at", + "booking_id", + "can_arrive", + "can_print_pass", + "can_reverse", + "display_name", + "ends_at", + "space_name", + "starts_at", + "status", + "timezone", + "waiver_state" + ] + }, + "BookingBroadcast": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "space": { + "type": "integer", + "readOnly": true + }, + "subject": { + "type": "string", + "readOnly": true, + "maxLength": 200 + }, + "body": { + "type": "string", + "readOnly": true, + "maxLength": 10000 + }, + "window_start": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "window_end": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "statuses": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Statuses219Enum" + }, + "readOnly": true, + "maxItems": 2, + "minItems": 1 + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusAdeEnum" + } + ], + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "queued_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "completed_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "body", + "completed_at", + "created_at", + "id", + "queued_at", + "space", + "status", + "statuses", + "subject", + "window_end", + "window_start" + ] + }, + "BookingBroadcastInput": { + "type": "object", + "properties": { + "subject": { + "type": "string", + "maxLength": 200 + }, + "body": { + "type": "string", + "maxLength": 10000 + }, + "window_start": { + "type": "string", + "format": "date-time" + }, + "window_end": { + "type": "string", + "format": "date-time" + }, + "statuses": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Statuses219Enum" + }, + "default": [ + "confirmed" + ], + "maxItems": 2, + "minItems": 1 + } + }, + "required": [ + "body", + "subject", + "window_end" + ] + }, + "BookingBroadcastList": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "format": "uri", + "nullable": true + }, + "previous": { + "type": "string", + "format": "uri", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingBroadcast" + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] + }, + "BookingCloneInput": { + "type": "object", + "properties": { + "copy_booking_rules": { + "type": "boolean", + "default": false + } + } + }, + "BookingConfirmation": { + "type": "object", + "properties": { + "public_token": { + "type": "string", + "format": "uuid" + }, + "space_name": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "starts_at": { + "type": "string", + "format": "date-time" + }, + "ends_at": { + "type": "string", + "format": "date-time" + }, + "status": { + "$ref": "#/components/schemas/Status2caEnum" + }, + "location": { + "type": "string" + }, + "description": { + "type": "string" + }, + "arrived_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "can_cancel": { + "type": "boolean" + }, + "cancellation_cutoff_at": { + "type": "string", + "format": "date-time" + }, + "pass_available": { + "type": "boolean", + "readOnly": true + }, + "qr_value": { + "type": "string", + "nullable": true + }, + "google_calendar_url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "arrived_at", + "can_cancel", + "cancellation_cutoff_at", + "description", + "ends_at", + "google_calendar_url", + "location", + "pass_available", + "public_token", + "qr_value", + "space_name", + "starts_at", + "status", + "timezone" + ] + }, + "BookingCredential": { + "type": "object", + "properties": { + "credential": { + "type": "string", + "writeOnly": true, + "maxLength": 128 + } + }, + "required": [ + "credential" + ] + }, + "BookingEligibleMember": { + "type": "object", + "properties": { + "member_id": { + "type": "integer", + "readOnly": true + }, + "display_name": { + "type": "string", + "readOnly": true + } + }, + "required": [ + "display_name", + "member_id" + ] + }, + "BookingEligibleMemberList": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingEligibleMember" + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] + }, + "BookingLeadTimeReport": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingLeadTimeRow" + } + } + }, + "required": [ + "rows", + "typed_rows" + ] + }, + "BookingLeadTimeRow": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer" + }, + "period": { + "type": "string", + "format": "date-time" + }, + "lead_time_bucket": { + "type": "string" + }, + "booking_count": { + "type": "integer" + } + }, + "required": [ + "booking_count", + "lead_time_bucket", + "period" + ] + }, + "BookingListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingAdmin" + } + } + }, + "required": [ + "count", + "results" + ] + }, + "BookingMachineType": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "name": { + "type": "string", + "readOnly": true + } + }, + "required": [ + "id", + "name" + ] + }, + "BookingMachineTypePage": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "format": "uri", + "nullable": true + }, + "previous": { + "type": "string", + "format": "uri", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingMachineType" + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] + }, + "BookingOccupancyReport": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingOccupancyRow" + } + } + }, + "required": [ + "rows", + "typed_rows" + ] + }, + "BookingOccupancyRow": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer" + }, + "period": { + "type": "string", + "format": "date-time" + }, + "weekday": { + "type": "integer", + "maximum": 6, + "minimum": 0 + }, + "hour": { + "type": "integer", + "maximum": 23, + "minimum": 0 + }, + "occupied_hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$" + }, + "window_hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$" + }, + "occupancy_percent": { + "type": "number", + "format": "double", + "nullable": true + } + }, + "required": [ + "hour", + "occupancy_percent", + "occupied_hours", + "period", + "weekday", + "window_hours" + ] + }, + "BookingPassElement": { + "type": "object", + "properties": { + "field": { + "$ref": "#/components/schemas/FieldEnum" + }, + "x_mm": { + "type": "number", + "format": "double", + "minimum": 0 + }, + "y_mm": { + "type": "number", + "format": "double", + "minimum": 0 + }, + "width_mm": { + "type": "number", + "format": "double", + "minimum": 0.5 + }, + "height_mm": { + "type": "number", + "format": "double", + "minimum": 0.5 + }, + "font": { + "$ref": "#/components/schemas/FontEnum" + }, + "font_size_pt": { + "type": "number", + "format": "double", + "maximum": 48, + "minimum": 6 + }, + "bold": { + "type": "boolean" + }, + "align": { + "$ref": "#/components/schemas/AlignEnum" + }, + "colour": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + } + }, + "required": [ + "field", + "height_mm", + "width_mm", + "x_mm", + "y_mm" + ] + }, + "BookingPassTemplate": { + "type": "object", + "properties": { + "version": { + "type": "integer", + "maximum": 2, + "minimum": 2 + }, + "paper_size": { + "$ref": "#/components/schemas/PaperSizeEnum" + }, + "orientation": { + "$ref": "#/components/schemas/OrientationEnum" + }, + "page_width_mm": { + "type": "number", + "format": "double", + "maximum": 500, + "minimum": 100, + "nullable": true + }, + "page_height_mm": { + "type": "number", + "format": "double", + "maximum": 500, + "minimum": 100, + "nullable": true + }, + "card_width_mm": { + "type": "number", + "format": "double", + "maximum": 150, + "minimum": 40 + }, + "card_height_mm": { + "type": "number", + "format": "double", + "maximum": 120, + "minimum": 30 + }, + "margin_mm": { + "type": "number", + "format": "double", + "maximum": 40, + "minimum": 0 + }, + "gap_mm": { + "type": "number", + "format": "double", + "maximum": 30, + "minimum": 0 + }, + "grid_mm": { + "type": "number", + "format": "double", + "maximum": 10, + "minimum": 0.5 + }, + "elements": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingPassElement" + } + } + } + }, + "BookingPublicRules": { + "type": "object", + "properties": { + "minimum_minutes": { + "type": "integer" + }, + "maximum_minutes": { + "type": "integer" + }, + "lead_minutes": { + "type": "integer" + }, + "advance_days": { + "type": "integer" + }, + "cancellation_cutoff_hours": { + "type": "integer" + } + }, + "required": [ + "advance_days", + "cancellation_cutoff_hours", + "lead_minutes", + "maximum_minutes", + "minimum_minutes" + ] + }, + "BookingReportMeta": { + "type": "object", + "properties": { + "source": { + "type": "string" + }, + "grain": { + "type": "string" + }, + "rollup_through": { + "type": "string", + "nullable": true + }, + "arrival_counts_available": { + "type": "boolean" + } + }, + "required": [ + "grain", + "rollup_through", + "source" + ] + }, + "BookingReschedule": { + "type": "object", + "properties": { + "starts_at": { + "type": "string", + "format": "date-time" + }, + "ends_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "ends_at", + "starts_at" + ] + }, + "BookingReservationReport": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "report_key": { + "type": "string" + }, + "meta": { + "$ref": "#/components/schemas/BookingReportMeta" + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingReservationReportRow" + } + } + }, + "required": [ + "meta", + "report_key", + "rows", + "typed_rows" + ] + }, + "BookingReservationReportRow": { + "type": "object", + "properties": { + "booking_id": { + "type": "integer" + }, + "space_id": { + "type": "integer" + }, + "space_name": { + "type": "string" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "starts_at": { + "type": "string", + "format": "date-time" + }, + "ends_at": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "booking_id", + "created_at", + "email", + "ends_at", + "name", + "phone", + "space_id", + "space_name", + "starts_at", + "status" + ] + }, + "BookingResourceUtilizationReport": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "report_key": { + "type": "string" + }, + "meta": { + "$ref": "#/components/schemas/BookingReportMeta" + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingResourceUtilizationRow" + } + } + }, + "required": [ + "meta", + "report_key", + "rows", + "typed_rows" + ] + }, + "BookingResourceUtilizationRow": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer" + }, + "space_id": { + "type": "integer" + }, + "space_name": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "booked": { + "type": "integer" + }, + "completed": { + "type": "integer" + }, + "no_show": { + "type": "integer" + }, + "cancelled": { + "type": "integer" + }, + "upcoming": { + "type": "integer" + }, + "reserved_hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + }, + "completed_hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + }, + "window_hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", + "nullable": true + }, + "reservation_utilization_percent": { + "type": "number", + "format": "double", + "nullable": true + }, + "no_show_rate_percent": { + "type": "number", + "format": "double", + "nullable": true + }, + "arrived_reservations": { + "type": "integer", + "description": "Reservations with a current, unreversed arrival." + }, + "confirmed_without_recorded_arrival": { + "type": "integer", + "description": "Confirmed reservations with no current arrival; not a no-show count." + } + }, + "required": [ + "arrived_reservations", + "booked", + "cancelled", + "completed", + "completed_hours", + "confirmed_without_recorded_arrival", + "is_active", + "kind", + "no_show", + "no_show_rate_percent", + "reservation_utilization_percent", + "reserved_hours", + "space_id", + "space_name", + "upcoming", + "window_hours" + ] + }, + "BookingStaffGrant": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "makerspace": { + "type": "integer", + "readOnly": true + }, + "space": { + "type": "integer", + "readOnly": true + }, + "user": { + "type": "integer", + "readOnly": true + }, + "permissions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Permissions43eEnum" + }, + "readOnly": true, + "maxItems": 4, + "minItems": 1 + }, + "granted_by": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "revoked_by": { + "type": "integer", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "created_at", + "granted_by", + "id", + "makerspace", + "permissions", + "revoked_at", + "revoked_by", + "space", + "user" + ] + }, + "BookingStaffGrantInvite": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "makerspace": { + "type": "integer", + "readOnly": true + }, + "space": { + "type": "integer", + "readOnly": true + }, + "permissions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Permissions43eEnum" + }, + "readOnly": true, + "maxItems": 4, + "minItems": 1 + }, + "created_by": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "expires_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "redeemed_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "created_at", + "created_by", + "expires_at", + "id", + "makerspace", + "permissions", + "redeemed_at", + "revoked_at", + "space" + ] + }, + "BookingStaffGrantInviteCreated": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "makerspace": { + "type": "integer", + "readOnly": true + }, + "space": { + "type": "integer", + "readOnly": true + }, + "permissions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Permissions43eEnum" + }, + "readOnly": true, + "maxItems": 4, + "minItems": 1 + }, + "created_by": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "expires_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "redeemed_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "token": { + "type": "string", + "readOnly": true + } + }, + "required": [ + "created_at", + "created_by", + "expires_at", + "id", + "makerspace", + "permissions", + "redeemed_at", + "revoked_at", + "space", + "token" + ] + }, + "BookingStaffGrantInviteInput": { + "type": "object", + "properties": { + "permissions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Permissions43eEnum" + }, + "maxItems": 4, + "minItems": 1 + }, + "email": { + "oneOf": [ + { + "type": "string", + "format": "email", + "default": "" + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "send_email": { + "type": "boolean", + "default": false + } + }, + "required": [ + "permissions" + ] + }, + "BookingStaffGrantInviteList": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "format": "uri", + "nullable": true + }, + "previous": { + "type": "string", + "format": "uri", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingStaffGrantInvite" + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] + }, + "BookingStaffGrantList": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "format": "uri", + "nullable": true + }, + "previous": { + "type": "string", + "format": "uri", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingStaffGrant" + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] + }, + "BookingStaffGrantRedeem": { + "type": "object", + "properties": { + "token": { + "type": "string", + "writeOnly": true, + "maxLength": 128, + "minLength": 32 + } + }, + "required": [ + "token" + ] + }, + "BookingTimeInput": { + "type": "object", + "properties": { + "starts_at": { + "type": "string", + "format": "date-time" + }, + "ends_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "ends_at", + "starts_at" + ] + }, + "BookingTopMemberRow": { + "type": "object", + "properties": { + "member_id": { + "type": "integer", + "nullable": true + }, + "member": { + "type": "string" + }, + "booking_count": { + "type": "integer" + }, + "reserved_hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$" + } + }, + "required": [ + "booking_count", + "member", + "member_id", + "reserved_hours" + ] + }, + "BookingTopMembersReport": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingTopMemberRow" + } + } + }, + "required": [ + "rows", + "typed_rows" + ] + }, + "BookingUtilizationReport": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingUtilizationRow" + } + } + }, + "required": [ + "rows", + "typed_rows" + ] + }, + "BookingUtilizationRow": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer" + }, + "space_id": { + "type": "integer" + }, + "space_name": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "booked": { + "type": "integer" + }, + "completed": { + "type": "integer" + }, + "no_show": { + "type": "integer" + }, + "cancelled": { + "type": "integer" + }, + "upcoming": { + "type": "integer" + }, + "reserved_hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + }, + "completed_hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + }, + "window_hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", + "nullable": true + }, + "reservation_utilization_percent": { + "type": "number", + "format": "double", + "nullable": true + }, + "no_show_rate_percent": { + "type": "number", + "format": "double", + "nullable": true + } + }, + "required": [ + "booked", + "cancelled", + "completed", + "completed_hours", + "is_active", + "kind", + "no_show", + "no_show_rate_percent", + "reservation_utilization_percent", + "reserved_hours", + "space_id", + "space_name", + "upcoming", + "window_hours" + ] + }, + "BookingUtilizationV2Report": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingUtilizationV2Row" + } + } + }, + "required": [ + "rows", + "typed_rows" + ] + }, + "BookingUtilizationV2Row": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer" + }, + "space_id": { + "type": "integer" + }, + "space_name": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "booked": { + "type": "integer" + }, + "completed": { + "type": "integer" + }, + "no_show": { + "type": "integer" + }, + "cancelled": { + "type": "integer" + }, + "upcoming": { + "type": "integer" + }, + "reserved_hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + }, + "completed_hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + }, + "window_hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", + "nullable": true + }, + "reservation_utilization_percent": { + "type": "number", + "format": "double", + "nullable": true + }, + "no_show_rate_percent": { + "type": "number", + "format": "double", + "nullable": true + }, + "period": { + "type": "string", + "format": "date-time" + }, + "reservations": { + "type": "integer" + }, + "terminal_reservations": { + "type": "integer" + }, + "cancellation_rate_percent": { + "type": "number", + "format": "double", + "nullable": true + } + }, + "required": [ + "booked", + "cancelled", + "completed", + "completed_hours", + "is_active", + "kind", + "no_show", + "no_show_rate_percent", + "reservation_utilization_percent", + "reserved_hours", + "space_id", + "space_name", + "upcoming", + "window_hours" + ] + }, + "BookingWebsite": { + "type": "object", + "properties": { + "public_url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "embed_path": { + "type": "string", + "readOnly": true + }, + "revision": { + "type": "integer" + }, + "released": { + "type": "boolean" + }, + "sections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingWebsiteSection" + } + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingWebsiteAsset" + } + } + }, + "required": [ + "embed_path", + "public_url", + "released", + "revision", + "sections" + ] + }, + "BookingWebsiteAsset": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "alt_text": { + "type": "string" + }, + "size_bytes": { + "type": "integer" + } + }, + "required": [ + "alt_text", + "id", + "size_bytes" + ] + }, + "BookingWebsiteAssetInput": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "uri" + }, + "alt_text": { + "type": "string", + "maxLength": 200 + }, + "replace_id": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "alt_text", + "file" + ] + }, + "BookingWebsiteContentInput": { + "type": "object", + "properties": { + "revision": { + "type": "integer", + "minimum": 0 + }, + "sections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingWebsiteSectionInput" + } + } + }, + "required": [ + "revision", + "sections" + ] + }, + "BookingWebsiteReleaseInput": { + "type": "object", + "properties": { + "revision": { + "type": "integer", + "minimum": 1 + }, + "released": { + "type": "boolean" + } + }, + "required": [ + "released", + "revision" + ] + }, + "BookingWebsiteSection": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/components/schemas/KindAf4Enum" + }, + "slug": { + "type": "string", + "maxLength": 80, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "title": { + "type": "string", + "maxLength": 200 + }, + "body": { + "type": "string", + "default": "", + "maxLength": 10000 + }, + "url": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "default": "", + "maxLength": 500 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "asset_id": { + "type": "integer", + "minimum": 1, + "nullable": true + }, + "visibility": { + "allOf": [ + { + "$ref": "#/components/schemas/VisibilityE49Enum" + } + ], + "default": "draft" + }, + "position": { + "type": "integer", + "maximum": 1000, + "minimum": 0, + "default": 0 + }, + "image_url": { + "type": "string", + "nullable": true + }, + "image_alt_text": { + "type": "string" + } + }, + "required": [ + "image_alt_text", + "image_url", + "kind", + "slug", + "title" + ] + }, + "BookingWebsiteSectionInput": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/components/schemas/KindAf4Enum" + }, + "slug": { + "type": "string", + "maxLength": 80, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "title": { + "type": "string", + "maxLength": 200 + }, + "body": { + "type": "string", + "default": "", + "maxLength": 10000 + }, + "url": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "default": "", + "maxLength": 500 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "asset_id": { + "type": "integer", + "minimum": 1, + "nullable": true + }, + "visibility": { + "allOf": [ + { + "$ref": "#/components/schemas/VisibilityE49Enum" + } + ], + "default": "draft" + }, + "position": { + "type": "integer", + "maximum": 1000, + "minimum": 0, + "default": 0 + } + }, + "required": [ + "kind", + "slug", + "title" + ] + }, + "Bounce": { + "type": "object", + "properties": { + "event_id": { + "type": "string", + "maxLength": 200 + }, + "message_id": { + "type": "string", + "maxLength": 255 + }, + "address": { + "type": "string", + "format": "email", + "maxLength": 254 + }, + "reason": { + "$ref": "#/components/schemas/BounceReasonEnum" + } + }, + "required": [ + "address", + "event_id", + "reason" + ] + }, + "BounceReasonEnum": { + "enum": [ + "bounce", + "complaint" + ], + "type": "string", + "description": "* `bounce` - bounce\n* `complaint` - complaint" + }, + "BounceResult": { + "type": "object", + "properties": { + "accepted": { + "type": "boolean" + } + }, + "required": [ + "accepted" + ] + }, + "Box": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "makerspace": { + "type": "integer", + "readOnly": true + }, + "parent": { + "type": "integer", + "nullable": true + }, + "code": { + "type": "string", + "readOnly": true + }, + "label": { + "type": "string", + "maxLength": 200 + }, + "location": { + "type": "string", + "maxLength": 200 + }, + "description": { + "type": "string" + }, + "kit_components": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KitComponent" + }, + "maxItems": 50 + }, + "is_active": { + "type": "boolean" + }, + "qr_code_id": { + "type": "integer", + "nullable": true, + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "code", + "created_at", + "id", + "label", + "makerspace", + "qr_code_id", + "updated_at" + ] + }, + "Broadcast": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "title": { + "type": "string", + "readOnly": true + }, + "body": { + "type": "string", + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "body", + "created_at", + "id", + "title" + ] + }, + "BroadcastInput": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 200 + }, + "body": { + "type": "string", + "maxLength": 12000 + }, + "plan_id": { + "type": "integer", + "minimum": 1 + }, + "status": { + "$ref": "#/components/schemas/BroadcastInputStatusEnum" + }, + "certification_id": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "body", + "title" + ] + }, + "BroadcastInputStatusEnum": { + "enum": [ + "active", + "lapsed" + ], + "type": "string", + "description": "* `active` - active\n* `lapsed` - lapsed" + }, + "BroadcastOptions": { + "type": "object", + "properties": { + "plans": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SegmentChoice" + } + }, + "certifications": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SegmentChoice" + } + } + }, + "required": [ + "certifications", + "plans" + ] + }, + "BroadcastResult": { + "type": "object", + "properties": { + "broadcast": { + "$ref": "#/components/schemas/Broadcast" + }, + "recipients": { + "type": "integer" + } + }, + "required": [ + "broadcast", + "recipients" + ] + }, + "BulkImportJob": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "mode": { + "allOf": [ + { + "$ref": "#/components/schemas/Mode087Enum" + } + ], + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/BulkImportJobStatusEnum" + } + ], + "readOnly": true + }, + "total_rows": { + "type": "integer", + "readOnly": true + }, + "processed_rows": { + "type": "integer", + "readOnly": true + }, + "created_count": { + "type": "integer", + "readOnly": true + }, + "updated_count": { + "type": "integer", + "readOnly": true + }, + "error_count": { + "type": "integer", + "readOnly": true + }, + "warning_count": { + "type": "integer", + "readOnly": true + }, + "result": { + "readOnly": true + }, + "error": { + "type": "string", + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "completed_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "completed_at", + "created_at", + "created_count", + "error", + "error_count", + "id", + "mode", + "processed_rows", + "result", + "status", + "total_rows", + "updated_at", + "updated_count", + "warning_count" + ] + }, + "BulkImportJobCreate": { + "type": "object", + "properties": { + "mode": { + "$ref": "#/components/schemas/Mode087Enum" + }, + "file": { + "type": "string", + "format": "uri", + "nullable": true + }, + "rows": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + }, + "maxItems": 5000 + }, + "mapping": {} + }, + "required": [ + "mode" + ] + }, + "BulkImportJobStatusEnum": { + "enum": [ + "pending", + "running", + "completed", + "failed" + ], + "type": "string", + "description": "* `pending` - Pending\n* `running` - Running\n* `completed` - Completed\n* `failed` - Failed" + }, + "BulkImportPreview": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "uri", + "nullable": true + }, + "rows": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + }, + "maxItems": 5000 + }, + "mapping": {} + } + }, + "BulkInviteInput": { + "type": "object", + "properties": { + "emails": { + "type": "array", + "items": { + "type": "string", + "format": "email" + }, + "maxItems": 200, + "minItems": 1 + }, + "expires_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "send_email": { + "type": "boolean", + "default": true + } + }, + "required": [ + "emails" + ] + }, + "BulkInviteResult": { + "type": "object", + "properties": { + "created": { + "type": "integer" + } + }, + "required": [ + "created" + ] + }, + "CadenceEnum": { + "enum": [ + "daily", + "weekly", + "monthly" + ], + "type": "string", + "description": "* `daily` - Daily\n* `weekly` - Weekly\n* `monthly` - Monthly" + }, + "CashBookReport": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CashPeriod" + } + } + }, + "required": [ + "rows", + "typed_rows" + ] + }, + "CashPeriod": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer" + }, + "currency": { + "type": "string" + }, + "collected_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + }, + "refunded_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + }, + "period": { + "type": "string", + "format": "date" + }, + "method": { + "type": "string" + }, + "net_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + } + }, + "required": [ + "collected_amount", + "currency", + "method", + "net_amount", + "period", + "refunded_amount" + ] + }, + "Category": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "name": { + "type": "string", + "readOnly": true + }, + "slug": { + "type": "string", + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" + } + }, + "required": [ + "id", + "name", + "slug" + ] + }, + "CategoryAdmin": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "makerspace": { + "type": "integer", + "readOnly": true + }, + "name": { + "type": "string", + "maxLength": 100 + }, + "slug": { + "oneOf": [ + { + "type": "string", + "pattern": "^[-a-zA-Z0-9_]+$" + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "display_order": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "loan_days": { + "type": "integer", + "maximum": 365, + "minimum": 1, + "nullable": true + }, + "max_renewals": { + "type": "integer", + "maximum": 100, + "minimum": 0, + "nullable": true + }, + "icon": { + "type": "string", + "maxLength": 50 + }, + "product_count": { + "type": "integer", + "readOnly": true, + "default": 0 + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "created_at", + "id", + "makerspace", + "name", + "product_count", + "updated_at" + ] + }, + "CategoryInput": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 100 + }, + "slug": { + "type": "string", + "maxLength": 100, + "pattern": "^[-a-zA-Z0-9_]+$" + } + }, + "required": [ + "name", + "slug" + ] + }, + "CategoryList": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "format": "uri", + "nullable": true + }, + "previous": { + "type": "string", + "format": "uri", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Category" + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] + }, + "CertificateArtwork": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + }, + "required": [ + "id" + ] + }, + "CertificateArtworkPresign": { + "type": "object", + "properties": { + "content_type": { + "$ref": "#/components/schemas/ContentTypeEnum" + }, + "content_length": { + "type": "integer", + "maximum": 10485760, + "minimum": 1 + } + }, + "required": [ + "content_length", + "content_type" + ] + }, + "CertificateArtworkUpload": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "upload_url": { + "type": "string", + "format": "uri" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "headers", + "id", + "upload_url" + ] + }, + "CertificateDownload": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "expires_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "expires_at", + "url" + ] + }, + "CertificateParameters": { + "type": "object", + "properties": { + "version": { + "type": "integer", + "maximum": 1, + "minimum": 1 + }, + "page_width_mm": { + "type": "number", + "format": "double", + "maximum": 420, + "minimum": 100 + }, + "page_height_mm": { + "type": "number", + "format": "double", + "maximum": 420, + "minimum": 100 + }, + "grid_mm": { + "type": "number", + "format": "double", + "maximum": 10, + "minimum": 0.5 + }, + "name_box": { + "$ref": "#/components/schemas/PositionedElement" + } + } + }, + "CertificateRevoke": { + "type": "object", + "properties": { + "reason": { + "$ref": "#/components/schemas/CertificateRevokeReasonEnum" + } + }, + "required": [ + "reason" + ] + }, + "CertificateRevokeReasonEnum": { + "enum": [ + "staff_revoked" + ], + "type": "string", + "description": "* `staff_revoked` - staff_revoked" + }, + "CertificateSummary": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/CertificateSummaryStatusEnum" + } + ], + "readOnly": true + }, + "revision": { + "type": "integer", + "readOnly": true + }, + "issued_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "rendered_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "id", + "issued_at", + "rendered_at", + "revision", + "revoked_at", + "status" + ] + }, + "CertificateSummaryStatusEnum": { + "enum": [ + "pending", + "rendering", + "active", + "failed", + "revoked" + ], + "type": "string", + "description": "* `pending` - Pending\n* `rendering` - Rendering\n* `active` - Active\n* `failed` - Failed\n* `revoked` - Revoked" + }, + "CertificateTemplateRevision": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "nullable": true + }, + "parameters": { + "$ref": "#/components/schemas/CertificateParameters" + }, + "artwork_id": { + "type": "integer", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "required": [ + "artwork_id", + "created_at", + "id", + "parameters" + ] + }, + "CertificateTemplateWrite": { + "type": "object", + "properties": { + "parameters": { + "$ref": "#/components/schemas/CertificateParameters" + }, + "artwork_id": { + "type": "integer", + "minimum": 1, + "nullable": true + } + }, + "required": [ + "parameters" + ] + }, + "CertificationGrant": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "certification_type": { + "type": "integer", + "readOnly": true + }, + "membership": { + "type": "integer", + "readOnly": true + }, + "member_name": { + "type": "string", + "readOnly": true + }, + "granted_by": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "granted_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "expires_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "revoked_by": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "notes": { + "type": "string", + "readOnly": true + }, + "is_live": { + "type": "string", + "readOnly": true + } + }, + "required": [ + "certification_type", + "expires_at", + "granted_at", + "granted_by", + "id", + "is_live", + "member_name", + "membership", + "notes", + "revoked_at", + "revoked_by" + ] + }, + "CertificationGrantCreate": { + "type": "object", + "properties": { + "membership_id": { + "type": "integer" + }, + "expires_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "notes": { + "type": "string", + "default": "" + } + }, + "required": [ + "membership_id" + ] + }, + "CertificationGrantPage": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CertificationGrant" + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] + }, + "CertificationGrantRevoke": { + "type": "object", + "properties": { + "notes": { + "type": "string", + "default": "" + } + } + }, + "CertificationMember": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "name": { + "type": "string", + "readOnly": true + } + }, + "required": [ + "id", + "name" + ] + }, + "CertificationMemberPage": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", "nullable": true }, - "current": { + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CertificationMember" + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] + }, + "CertificationType": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "machine_type": { + "type": "integer" + }, + "name": { + "type": "string", + "maxLength": 200 + }, + "description": { + "type": "string" + }, + "validity_days": { + "type": "integer", + "maximum": 2147483647, + "minimum": 1, + "nullable": true + }, + "is_required_for_service": { + "type": "boolean" + }, + "is_required_for_booking": { "type": "boolean" + }, + "is_active": { + "type": "boolean", + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true } }, "required": [ "created_at", - "current", - "expires_at", "id", - "kind", - "last_used_at" + "is_active", + "machine_type", + "name", + "updated_at" ] }, - "AccountSessionKindEnum": { - "enum": [ - "web", - "device" - ], - "type": "string", - "description": "* `web` - web\n* `device` - device" - }, - "AccountSessionPage": { + "CertificationTypePage": { "type": "object", "properties": { "count": { @@ -67714,18 +89323,16 @@ }, "next": { "type": "string", - "format": "uri", "nullable": true }, "previous": { "type": "string", - "format": "uri", "nullable": true }, "results": { "type": "array", "items": { - "$ref": "#/components/schemas/AccountSession" + "$ref": "#/components/schemas/CertificationType" } } }, @@ -67736,2393 +89343,2163 @@ "results" ] }, - "AccountingJournalReport": { + "ChainStatusEnum": { + "enum": [ + "pending", + "locally_verified", + "invalid", + "unavailable" + ], + "type": "string", + "description": "* `pending` - Pending\n* `locally_verified` - Locally verified\n* `invalid` - Invalid\n* `unavailable` - Unavailable" + }, + "ChallengeRequest": { "type": "object", "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } + "token": { + "type": "string", + "maxLength": 256 }, - "typed_rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/JournalLine" - } + "public_key": { + "type": "string", + "maxLength": 64 } }, "required": [ - "rows", - "typed_rows" + "public_key", + "token" ] }, - "Ack": { + "ChallengeResponse": { "type": "object", "properties": { - "lease_token": { + "challenge": { + "type": "string" + }, + "expires_at": { "type": "string", - "maxLength": 128, - "minLength": 32 + "format": "date-time" } }, "required": [ - "lease_token" + "challenge", + "expires_at" ] }, - "ActiveLoansReport": { + "ChangePassword": { "type": "object", "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } + "current_password": { + "type": "string", + "writeOnly": true }, - "typed_rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ActiveLoansReportRow" - } + "new_password": { + "type": "string", + "writeOnly": true } }, "required": [ - "rows", - "typed_rows" + "current_password", + "new_password" ] }, - "ActiveLoansReportRow": { + "ChangePasswordResponse": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" + "detail": { + "type": "string" + } + }, + "required": [ + "detail" + ] + }, + "Channel06eEnum": { + "enum": [ + "sms", + "whatsapp" + ], + "type": "string", + "description": "* `sms` - sms\n* `whatsapp` - whatsapp" + }, + "Channel292Enum": { + "enum": [ + "in_app", + "email", + "telegram", + "slack", + "mattermost", + "discord", + "webhook", + "native_push", + "sms", + "whatsapp" + ], + "type": "string", + "description": "* `in_app` - Inbox\n* `email` - Email\n* `telegram` - Telegram\n* `slack` - Slack\n* `mattermost` - Mattermost\n* `discord` - Discord\n* `webhook` - Signed webhook\n* `native_push` - Native push\n* `sms` - SMS\n* `whatsapp` - WhatsApp" + }, + "Channel59dEnum": { + "enum": [ + "telegram", + "slack", + "mattermost", + "discord", + "webhook" + ], + "type": "string", + "description": "* `telegram` - Telegram\n* `slack` - Slack\n* `mattermost` - Mattermost\n* `discord` - Discord\n* `webhook` - Signed webhook" + }, + "ChannelB6fEnum": { + "enum": [ + "email", + "in_app", + "native_push", + "sms", + "whatsapp" + ], + "type": "string", + "description": "* `email` - email\n* `in_app` - in_app\n* `native_push` - native_push\n* `sms` - sms\n* `whatsapp` - whatsapp" + }, + "ChannelsEnum": { + "enum": [ + "email", + "telegram", + "slack", + "mattermost", + "discord", + "native_push", + "webhook", + "sms", + "whatsapp" + ], + "type": "string", + "description": "* `email` - email\n* `telegram` - telegram\n* `slack` - slack\n* `mattermost` - mattermost\n* `discord` - discord\n* `native_push` - native_push\n* `webhook` - webhook\n* `sms` - sms\n* `whatsapp` - whatsapp" + }, + "CheckoutUrl": { + "type": "object", + "properties": { + "checkout_url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "checkout_url" + ] + }, + "ClaimRedemption": { + "type": "object", + "properties": { + "code": { + "type": "string", + "writeOnly": true, + "maxLength": 64 + }, + "makerspace_slug": { + "type": "string", + "writeOnly": true, + "maxLength": 80, + "pattern": "^[-a-zA-Z0-9_]+$" + } + }, + "required": [ + "code", + "makerspace_slug" + ] + }, + "ClaimRedemptionResponse": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/AuthUserPayload" }, + "access": { + "type": "string" + } + }, + "required": [ + "access", + "user" + ] + }, + "ClaimableInvitation": { + "type": "object", + "properties": { "id": { "type": "integer" }, - "requester": { - "type": "string" + "makerspace": { + "type": "object", + "additionalProperties": {} }, - "status": { - "type": "string" + "inviter": { + "type": "string", + "nullable": true }, - "issued_at": { + "auto_activates": { + "type": "boolean" + }, + "is_staff": { + "type": "boolean" + }, + "role": { "type": "string", - "format": "date-time", "nullable": true } }, "required": [ + "auto_activates", "id", - "issued_at", - "requester", - "status" + "inviter", + "is_staff", + "makerspace", + "role" ] }, - "AddressingStyleEnum": { + "ClientPlatformEnum": { "enum": [ - "auto", - "path", - "virtual" + "web", + "ios", + "android" ], "type": "string", - "description": "* `auto` - Auto\n* `path` - Path\n* `virtual` - Virtual host" + "description": "* `web` - Web\n* `ios` - iOS\n* `android` - Android" }, - "AdminMembership": { + "ClientTypeEnum": { + "enum": [ + "browser", + "server" + ], + "type": "string", + "description": "* `browser` - Browser\n* `server` - Server" + }, + "ClosureApproval": { "type": "object", "properties": { "id": { - "type": "integer", + "type": "string", + "format": "uuid", "readOnly": true }, - "status": { - "$ref": "#/components/schemas/Status37fEnum" - }, - "user": { - "allOf": [ - { - "$ref": "#/components/schemas/AdminMembershipUser" - } - ], - "readOnly": true + "closure_digest": { + "type": "string", + "maxLength": 64 }, - "assigned_role": { - "allOf": [ - { - "$ref": "#/components/schemas/AdminMembershipRole" - } - ], - "nullable": true, + "identity_count": { + "type": "string", "readOnly": true }, - "can_refer": { - "type": "boolean" - }, - "can_verify": { - "type": "boolean" - }, - "verified_at": { + "approved_count": { "type": "string", - "format": "date-time", - "nullable": true + "readOnly": true }, - "activated_at": { + "approved_at": { "type": "string", "format": "date-time", - "nullable": true + "readOnly": true }, "revoked_at": { "type": "string", "format": "date-time", "nullable": true - }, - "revocation_reason": { - "type": "string" - }, - "waiver_accepted_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "waiver_version_accepted": { + } + }, + "required": [ + "approved_at", + "approved_count", + "closure_digest", + "id", + "identity_count" + ] + }, + "ClosureApprovalCreate": { + "type": "object", + "properties": { + "digest": { "type": "string", - "nullable": true, - "maxLength": 64 - }, - "waiver_current": { - "type": "boolean", - "readOnly": true - }, - "waiver_required": { - "type": "boolean", - "readOnly": true - }, - "payment": { - "allOf": [ - { - "$ref": "#/components/schemas/StaffPaymentSummary" - } - ], - "nullable": true, - "readOnly": true + "maxLength": 64, + "minLength": 64 }, - "vouches": { + "decisions": { "type": "array", "items": { - "$ref": "#/components/schemas/Vouch" - }, - "readOnly": true + "$ref": "#/components/schemas/IdentityDisclosureDecision" + } } }, "required": [ - "assigned_role", - "id", - "payment", - "user", - "vouches", - "waiver_current", - "waiver_required" + "decisions", + "digest" ] }, - "AdminMembershipRole": { + "ClosureIdentity": { "type": "object", "properties": { "id": { "type": "integer" }, - "name": { + "username": { "type": "string" }, - "slug": { + "email": { + "oneOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "phone": { "type": "string" + }, + "date_joined": { + "type": "string", + "format": "date-time" } }, "required": [ + "date_joined", + "display_name", + "email", + "first_name", "id", - "name", - "slug" + "last_name", + "phone", + "username" ] }, - "AdminMembershipUser": { + "CollaborativeEvent": { "type": "object", "properties": { - "id": { - "type": "integer" + "participating_organizations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParticipatingOrganization" + }, + "readOnly": true }, - "username": { - "type": "string" + "audience_label": { + "type": "string", + "nullable": true, + "readOnly": true + }, + "registration_audience": { + "allOf": [ + { + "$ref": "#/components/schemas/EventDefaultRegistrationAudienceEnum" + } + ], + "readOnly": true + }, + "category": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "readOnly": true + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "readOnly": true + }, + "ticket_types": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "audience": { + "type": "string" + }, + "spots_left": { + "type": "integer", + "nullable": true + } + } + }, + "readOnly": true }, - "email": { - "type": "string", - "format": "email" + "promo_codes_enabled": { + "type": "boolean", + "readOnly": true }, - "display_name": { - "type": "string" - } - }, - "required": [ - "display_name", - "email", - "id", - "username" - ] - }, - "AdminRequest": { - "type": "object", - "properties": { - "has_kit_checklist": { + "ticket_selection_required": { "type": "boolean", "readOnly": true }, - "id": { - "type": "integer", + "show_registration_count": { + "type": "boolean", "readOnly": true }, - "makerspace_id": { + "registration_count": { "type": "integer", + "nullable": true, "readOnly": true }, - "requester_username": { + "payment_amount": { "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", "readOnly": true }, - "requester_name": { + "currency": { "type": "string", "readOnly": true }, - "requester_display": { - "type": "string", + "id": { + "type": "integer", "readOnly": true }, - "requester_contact_email": { + "title": { "type": "string", - "format": "email", "readOnly": true }, - "requester_contact_phone": { + "description": { "type": "string", "readOnly": true }, - "requester_contact_verified": { - "type": "boolean", - "readOnly": true - }, - "status": { + "starts_at": { "type": "string", + "format": "date-time", "readOnly": true }, - "requested_for": { + "ends_at": { "type": "string", + "format": "date-time", "readOnly": true }, - "rejection_reason": { + "location": { "type": "string", "readOnly": true }, - "assigned_box_label": { - "type": "string", - "readOnly": true, - "nullable": true - }, - "accepted_by": { + "location_kind": { "allOf": [ { - "$ref": "#/components/schemas/AdminRequestActor" + "$ref": "#/components/schemas/LocationKindEnum" } ], + "readOnly": true + }, + "custom_form": { "readOnly": true, "nullable": true }, - "issued_by": { + "capacity": { + "type": "integer", + "minimum": 0, + "readOnly": true + }, + "availability": { "allOf": [ { - "$ref": "#/components/schemas/AdminRequestActor" + "$ref": "#/components/schemas/AvailabilityEnum" } ], - "readOnly": true, - "nullable": true - }, - "accepted_at": { - "type": "string", - "format": "date-time", "readOnly": true }, - "issued_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "return_due_at": { - "type": "string", - "format": "date-time", + "registration_requires_approval": { + "type": "boolean", "readOnly": true }, - "return_reminder_sent_at": { + "effective_registration_cutoff_at": { "type": "string", "format": "date-time", + "nullable": true, "readOnly": true }, - "renewal_count": { - "type": "integer", + "registration_open": { + "type": "boolean", "readOnly": true }, - "closed_at": { + "image_url": { "type": "string", - "format": "date-time", + "format": "uri", + "nullable": true, "readOnly": true }, - "created_at": { + "host_name": { "type": "string", - "format": "date-time", "readOnly": true }, - "updated_at": { + "host_slug": { "type": "string", - "format": "date-time", - "readOnly": true - }, - "issue_evidence_id": { - "type": "integer", "readOnly": true, - "nullable": true + "pattern": "^[-a-zA-Z0-9_]+$" }, - "return_evidence_ids": { - "type": "array", - "items": {}, + "host_waiver": { + "allOf": [ + { + "$ref": "#/components/schemas/HostWaiver" + } + ], + "nullable": true, "readOnly": true }, - "items": { + "consent_repair_required": { + "type": "boolean", + "readOnly": true + }, + "organizers": { "type": "array", "items": { - "$ref": "#/components/schemas/AdminRequestItem" + "$ref": "#/components/schemas/EventOrganizerSummary" }, "readOnly": true - } - }, - "required": [ - "accepted_at", - "accepted_by", - "assigned_box_label", - "closed_at", - "created_at", - "has_kit_checklist", - "id", - "issue_evidence_id", - "issued_at", - "issued_by", - "items", - "makerspace_id", - "rejection_reason", - "renewal_count", - "requested_for", - "requester_contact_email", - "requester_contact_phone", - "requester_contact_verified", - "requester_display", - "requester_name", - "requester_username", - "return_due_at", - "return_evidence_ids", - "return_reminder_sent_at", - "status", - "updated_at" - ] - }, - "AdminRequestActor": { - "type": "object", - "properties": { - "username": { - "type": "string", - "readOnly": true }, - "role": { - "type": "string", + "series": { + "type": "object", + "nullable": true, "readOnly": true } }, "required": [ - "role", - "username" + "audience_label", + "availability", + "capacity", + "category", + "consent_repair_required", + "currency", + "custom_form", + "description", + "effective_registration_cutoff_at", + "ends_at", + "host_name", + "host_slug", + "host_waiver", + "id", + "image_url", + "location", + "location_kind", + "organizers", + "participating_organizations", + "payment_amount", + "promo_codes_enabled", + "registration_audience", + "registration_count", + "registration_open", + "registration_requires_approval", + "series", + "show_registration_count", + "starts_at", + "tags", + "ticket_selection_required", + "ticket_types", + "title" ] }, - "AdminRequestItem": { + "CollaborativeEventRegistrationInput": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true + "custom_answers": { + "writeOnly": true, + "nullable": true }, - "product_id": { + "host_waiver_id": { "type": "integer", - "readOnly": true - }, - "product_name": { - "type": "string", - "readOnly": true - }, - "storage_location": { - "type": "string", - "readOnly": true + "minimum": 1, + "nullable": true }, - "tracking_mode": { + "host_waiver_version": { "type": "string", - "readOnly": true + "nullable": true, + "maxLength": 64 }, - "requires_asset_qr": { + "host_waiver_accepted": { "type": "boolean", - "readOnly": true - }, - "requested_quantity": { - "type": "integer", - "readOnly": true - }, - "accepted_quantity": { - "type": "integer", - "readOnly": true - }, - "issued_quantity": { - "type": "integer", - "readOnly": true + "default": false }, - "returned_quantity": { - "type": "integer", - "readOnly": true + "phone": { + "type": "string", + "writeOnly": true, + "maxLength": 64 }, - "damaged_quantity": { - "type": "integer", - "readOnly": true + "email": { + "writeOnly": true, + "oneOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "string", + "maxLength": 0 + } + ] }, - "missing_quantity": { - "type": "integer", - "readOnly": true + "invite_code": { + "type": "string", + "writeOnly": true, + "maxLength": 128 }, - "needs_fix_quantity": { + "ticket_type_id": { "type": "integer", - "readOnly": true + "minimum": 1, + "writeOnly": true }, - "issued_assets": { - "type": "array", - "items": {}, - "readOnly": true - } - }, - "required": [ - "accepted_quantity", - "damaged_quantity", - "id", - "issued_assets", - "issued_quantity", - "missing_quantity", - "needs_fix_quantity", - "product_id", - "product_name", - "requested_quantity", - "requires_asset_qr", - "returned_quantity", - "storage_location", - "tracking_mode" - ] - }, - "AdmissionAnswers": { - "type": "object", - "properties": { - "application_answers": { - "nullable": true + "promo_code": { + "type": "string", + "writeOnly": true, + "maxLength": 128 } } }, - "AdmissionExport": { + "CommandAck": { "type": "object", "properties": { - "requests": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AdmissionRequest" - } + "id": { + "type": "string", + "format": "uuid" + }, + "status": { + "type": "string" + }, + "acknowledged_at": { + "type": "string", + "format": "date-time", + "nullable": true } }, "required": [ - "requests" + "acknowledged_at", + "id", + "status" ] }, - "AdmissionPayment": { + "CommandLease": { "type": "object", "properties": { - "id": { - "type": "integer" + "command_id": { + "type": "string", + "format": "uuid" }, - "status": { + "kind": { "type": "string" }, - "amount": { + "lease_token": { + "type": "string" + }, + "lease_expires_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "format": "date-time" }, - "currency": { + "printer_job_id": { + "type": "string", + "format": "uuid" + }, + "external_job_id": { "type": "string" + }, + "service_request_id": { + "type": "integer" + }, + "label_command_id": { + "type": "string", + "format": "uuid" + }, + "qr_batch_id": { + "type": "integer" + }, + "item_count": { + "type": "integer" } }, "required": [ - "amount", - "currency", - "id", - "status" + "command_id", + "kind", + "lease_expires_at", + "lease_token" ] }, - "AdmissionRequest": { + "CommunicationsHealthReport": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "state": { - "$ref": "#/components/schemas/State9efEnum" - }, - "application_answers": { - "nullable": true, - "readOnly": true + "report_key": { + "type": "string" }, - "vouches": { + "rows": { "type": "array", "items": { - "$ref": "#/components/schemas/Vouch" + "type": "array", + "items": {} } }, - "admission_payment": { - "allOf": [ - { - "$ref": "#/components/schemas/AdmissionPayment" - } - ], - "nullable": true + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CommunicationsHealthRow" + } }, - "expires_at": { - "type": "string", - "format": "date-time", - "nullable": true + "meta": { + "type": "object", + "additionalProperties": {} } }, "required": [ - "admission_payment", - "application_answers", - "id", - "state", - "vouches" + "rows", + "typed_rows" ] }, - "AdmissionSettings": { + "CommunicationsHealthRow": { "type": "object", "properties": { - "configured": { - "type": "boolean", - "readOnly": true + "triggered_count": { + "type": "integer", + "minimum": 0 }, - "join_mode": { - "$ref": "#/components/schemas/JoinModeEnum" + "delivery_count": { + "type": "integer", + "minimum": 0 }, - "vouches_required": { + "attempt_count": { "type": "integer", - "maximum": 5, - "minimum": 1 + "minimum": 0 }, - "voucher_min_tenure_days": { + "sent_count": { "type": "integer", - "maximum": 3650, "minimum": 0 }, - "voucher_pending_limit": { + "failed_count": { "type": "integer", - "maximum": 100, - "minimum": 1 + "minimum": 0 }, - "vouch_expiry_days": { + "skipped_count": { "type": "integer", - "maximum": 365, - "minimum": 1 + "minimum": 0 }, - "vouch_replaces_approval": { - "type": "boolean" + "suppressed_count": { + "type": "integer", + "minimum": 0 }, - "payment_timing": { - "$ref": "#/components/schemas/PaymentTimingEnum" + "bounced_count": { + "type": "integer", + "minimum": 0 }, - "application_schema": { - "nullable": true + "complained_count": { + "type": "integer", + "minimum": 0 }, - "default_plan": { + "invalid_count": { "type": "integer", - "nullable": true + "minimum": 0 }, - "joining_fee": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "inbox_created_count": { + "type": "integer", + "minimum": 0 }, - "joining_fee_currency": { - "type": "string", - "pattern": "^[A-Za-z]{3}$" + "inbox_read_count": { + "type": "integer", + "minimum": 0 }, - "rejoin_dues_rule": { - "$ref": "#/components/schemas/RejoinDuesRuleEnum" + "unread_count": { + "type": "integer", + "minimum": 0 }, - "request_expiry_days": { + "accepted_count": { "type": "integer", - "maximum": 365, - "minimum": 1 + "minimum": 0 + }, + "delivered_count": { + "type": "integer", + "minimum": 0 + }, + "read_count": { + "type": "integer", + "minimum": 0 + }, + "pending_count": { + "type": "integer", + "minimum": 0 + }, + "sending_count": { + "type": "integer", + "minimum": 0 + }, + "accepted_only_count": { + "type": "integer", + "minimum": 0 + }, + "sent_only_count": { + "type": "integer", + "minimum": 0 }, - "recipe": { - "allOf": [ - { - "$ref": "#/components/schemas/RecipeEnum" - } - ], - "writeOnly": true - } - }, - "required": [ - "configured" - ] - }, - "AdmissionVouchOutcome": { - "type": "object", - "description": "A voucher receives the outcome, never the applicant's private submission.", - "properties": { - "id": { + "delivered_only_count": { "type": "integer", - "readOnly": true + "minimum": 0 }, - "state": { - "$ref": "#/components/schemas/State9efEnum" - } - }, - "required": [ - "id", - "state" - ] - }, - "AllowedFormatsEnum": { - "enum": [ - "csv", - "xlsx" - ], - "type": "string", - "description": "* `csv` - csv\n* `xlsx` - xlsx" - }, - "AnalyticsReportResponse": { - "oneOf": [ - { - "$ref": "#/components/schemas/AnalyticsSummary" + "estimated_segments": { + "type": "integer", + "minimum": 0 }, - { - "$ref": "#/components/schemas/TakenItemsReport" + "billed_segments": { + "type": "integer", + "minimum": 0 }, - { - "$ref": "#/components/schemas/ActiveLoansReport" + "segments": { + "type": "integer", + "minimum": 0 }, - { - "$ref": "#/components/schemas/ReturnsReport" + "abandoned_webhook_count": { + "type": "integer", + "minimum": 0 }, - { - "$ref": "#/components/schemas/DamagedMissingReport" + "delivery_rate_numerator": { + "type": "integer", + "minimum": 0 }, - { - "$ref": "#/components/schemas/DamagedLostReport" + "delivery_rate_denominator": { + "type": "integer", + "minimum": 0 }, - { - "$ref": "#/components/schemas/QrScansReport" + "read_rate_numerator": { + "type": "integer", + "minimum": 0 }, - { - "$ref": "#/components/schemas/MostLentReport" + "read_rate_denominator": { + "type": "integer", + "minimum": 0 }, - { - "$ref": "#/components/schemas/TopBorrowersReport" + "makerspace_id": { + "type": "integer" }, - { - "$ref": "#/components/schemas/RecentlyAddedReport" + "period": { + "type": "string", + "format": "date", + "nullable": true, + "description": "Null for the current consent snapshot." }, - { - "$ref": "#/components/schemas/MachineUsageReport" + "module_key": { + "type": "string" }, - { - "$ref": "#/components/schemas/EventAttendanceReport" + "channel": { + "type": "string" }, - { - "$ref": "#/components/schemas/BookingUtilizationReport" + "feature": { + "type": "string" }, - { - "$ref": "#/components/schemas/MaintenanceActivityReport" + "provider_source": { + "type": "string" }, - { - "$ref": "#/components/schemas/MemberActivityReport" + "platform": { + "type": "string" }, - { - "$ref": "#/components/schemas/FabLabHealthReport" + "audience": { + "type": "string" }, - { - "$ref": "#/components/schemas/PaymentReconciliationReport" + "skip_reason": { + "type": "string" }, - { - "$ref": "#/components/schemas/PaymentCashBook" + "segment_source": { + "$ref": "#/components/schemas/SegmentSourceEnum" }, - { - "$ref": "#/components/schemas/PaymentsLedgerReport" + "measurement": { + "$ref": "#/components/schemas/CommunicationsHealthRowMeasurementEnum" }, - { - "$ref": "#/components/schemas/RevenueOverTimeReport" + "success_rate_percent": { + "type": "number", + "format": "double", + "nullable": true }, - { - "$ref": "#/components/schemas/CashBookReport" + "read_rate_percent": { + "type": "number", + "format": "double", + "nullable": true }, - { - "$ref": "#/components/schemas/AccountingJournalReport" + "delivery_rate": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Delivered / accepted; null for a zero denominator." }, - { - "$ref": "#/components/schemas/GenericAnalyticsReport" + "read_rate": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Read / delivered; null for a zero denominator." }, - { - "$ref": "#/components/schemas/MemberRetentionReport" + "consented_member_count": { + "type": "integer", + "nullable": true, + "description": "Current live consent among active local members; excluded from organization totals." }, - { - "$ref": "#/components/schemas/MembershipRevenueReport" + "last_activity_at": { + "type": "string", + "format": "date-time", + "nullable": true } - ] + }, + "required": [ + "abandoned_webhook_count", + "accepted_count", + "accepted_only_count", + "attempt_count", + "audience", + "billed_segments", + "bounced_count", + "channel", + "complained_count", + "consented_member_count", + "delivered_count", + "delivered_only_count", + "delivery_count", + "delivery_rate", + "delivery_rate_denominator", + "delivery_rate_numerator", + "estimated_segments", + "failed_count", + "feature", + "inbox_created_count", + "inbox_read_count", + "invalid_count", + "last_activity_at", + "measurement", + "module_key", + "pending_count", + "period", + "platform", + "provider_source", + "read_count", + "read_rate", + "read_rate_denominator", + "read_rate_numerator", + "read_rate_percent", + "segment_source", + "segments", + "sending_count", + "sent_count", + "sent_only_count", + "skip_reason", + "skipped_count", + "success_rate_percent", + "suppressed_count", + "triggered_count", + "unread_count" + ] + }, + "CommunicationsHealthRowMeasurementEnum": { + "enum": [ + "delivery_cohort", + "webhook_receipts", + "current_consent_snapshot" + ], + "type": "string", + "description": "* `delivery_cohort` - delivery_cohort\n* `webhook_receipts` - webhook_receipts\n* `current_consent_snapshot` - current_consent_snapshot" }, - "AnalyticsSummary": { + "CompleteRequest": { "type": "object", "properties": { - "products": { - "type": "integer" - }, - "assets": { - "type": "integer" - }, - "active_loans": { - "type": "integer" - }, - "available_quantity": { - "type": "integer" + "token": { + "type": "string", + "maxLength": 256 }, - "issued_quantity": { - "type": "integer" + "public_key": { + "type": "string", + "maxLength": 64 }, - "damaged_quantity": { - "type": "integer" + "challenge": { + "type": "string", + "maxLength": 64 }, - "missing_quantity": { - "type": "integer" + "signature": { + "type": "string", + "maxLength": 128 } }, "required": [ - "active_loans", - "assets", - "available_quantity", - "damaged_quantity", - "issued_quantity", - "missing_quantity", - "products" + "challenge", + "public_key", + "signature", + "token" ] }, - "AnchorEnum": { + "ConditionEnum": { "enum": [ - "completion", - "calendar" + "available", + "damaged", + "lost", + "unknown" ], "type": "string", - "description": "* `completion` - Completion date\n* `calendar` - Fixed calendar" + "description": "* `available` - Available\n* `damaged` - Damaged\n* `lost` - Lost\n* `unknown` - Unknown" }, - "AnchorStatusEnum": { + "ConfirmEnum": { + "enum": [ + "DELETE" + ], + "type": "string", + "description": "* `DELETE` - DELETE" + }, + "ConnectStatusEnum": { "enum": [ + "unconnected", "pending", - "not_configured", - "externally_anchored", - "invalid", - "unavailable" + "active", + "restricted", + "disconnected" ], "type": "string", - "description": "* `pending` - Pending\n* `not_configured` - Not configured\n* `externally_anchored` - Externally anchored\n* `invalid` - Invalid\n* `unavailable` - Unavailable" + "description": "* `unconnected` - Unconnected\n* `pending` - Pending\n* `active` - Active\n* `restricted` - Restricted\n* `disconnected` - Disconnected" }, - "ApiClient": { + "ConnectedDeviceIdentity": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "label": { - "type": "string", - "maxLength": 200 - }, - "client_id": { - "type": "string", - "readOnly": true - }, - "client_type": { - "$ref": "#/components/schemas/ClientTypeEnum" - }, - "scopes": { - "type": "array", - "items": { - "type": "string" - } - }, - "rate_limit_tier": { - "$ref": "#/components/schemas/RateLimitTierEnum" - }, - "makerspace": { - "type": "integer", - "readOnly": true, - "nullable": true - }, - "public_makerspace_code": { + "device_id": { "type": "string", - "readOnly": true - }, - "allowed_origins": { - "type": "array", - "items": { - "type": "string" - } + "format": "uuid" }, - "backend_base_url": { - "type": "string", - "readOnly": true + "makerspace_id": { + "type": "integer" }, - "public_api_base_url": { - "type": "string", - "readOnly": true + "status": { + "type": "string" }, - "is_active": { - "type": "boolean" + "key_version": { + "type": "integer" }, "last_seen_at": { "type": "string", "format": "date-time", - "readOnly": true, - "nullable": true - }, - "last_seen_ip": { - "type": "string", - "readOnly": true, "nullable": true - }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "updated_at": { - "type": "string", - "format": "date-time", - "readOnly": true } }, "required": [ - "allowed_origins", - "backend_base_url", - "client_id", - "created_at", - "id", - "label", + "device_id", + "key_version", "last_seen_at", - "last_seen_ip", - "makerspace", - "public_api_base_url", - "public_makerspace_code", - "scopes", - "updated_at" + "makerspace_id", + "status" ] }, - "ApiClientCreateResponse": { + "ConsentWrite": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "label": { - "type": "string", - "maxLength": 200 - }, - "client_id": { - "type": "string", - "readOnly": true - }, - "client_secret": { - "type": "string", - "readOnly": true - }, - "client_type": { - "$ref": "#/components/schemas/ClientTypeEnum" - }, - "scopes": { - "type": "array", - "items": { - "type": "string" - } - }, - "rate_limit_tier": { - "$ref": "#/components/schemas/RateLimitTierEnum" - }, "makerspace": { "type": "integer", - "readOnly": true, - "nullable": true - }, - "public_makerspace_code": { - "type": "string", - "readOnly": true - }, - "allowed_origins": { - "type": "array", - "items": { - "type": "string" - } + "minimum": 1 }, - "backend_base_url": { - "type": "string", - "readOnly": true + "channel": { + "$ref": "#/components/schemas/Channel06eEnum" }, - "public_api_base_url": { - "type": "string", + "action": { + "$ref": "#/components/schemas/ConsentWriteActionEnum" + } + }, + "required": [ + "action", + "channel", + "makerspace" + ] + }, + "ConsentWriteActionEnum": { + "enum": [ + "opted_in", + "opted_out" + ], + "type": "string", + "description": "* `opted_in` - opted_in\n* `opted_out` - opted_out" + }, + "ConsumableCandidate": { + "type": "object", + "properties": { + "id": { + "type": "integer", "readOnly": true }, - "is_active": { - "type": "boolean" - }, - "last_seen_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "last_seen_ip": { - "type": "string", - "readOnly": true, - "nullable": true - }, - "created_at": { + "name": { "type": "string", - "format": "date-time", "readOnly": true }, - "updated_at": { - "type": "string", - "format": "date-time", + "available": { + "type": "integer", "readOnly": true } }, "required": [ - "allowed_origins", - "backend_base_url", - "client_id", - "client_secret", - "created_at", + "available", "id", - "label", - "last_seen_at", - "last_seen_ip", - "makerspace", - "public_api_base_url", - "public_makerspace_code", - "scopes", - "updated_at" + "name" ] }, - "ApiClientScopeCatalogResponse": { + "Consumption": { "type": "object", "properties": { - "count": { - "type": "integer" - }, - "next": { - "type": "string", - "nullable": true + "machine_consumable_id": { + "type": "integer", + "minimum": 1 }, - "previous": { + "quantity": { "type": "string", - "nullable": true - }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ApiClientScopeOption" - } + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" } }, "required": [ - "count", - "results" + "machine_consumable_id", + "quantity" ] }, - "ApiClientScopeOption": { + "ContainerAssetSummary": { "type": "object", "properties": { - "value": { - "type": "string" + "id": { + "type": "integer" }, - "label": { + "asset_tag": { "type": "string" }, - "description": { + "product": { "type": "string" }, - "group": { + "status": { "type": "string" + } + }, + "required": [ + "asset_tag", + "id", + "product", + "status" + ] + }, + "ContainerContents": { + "type": "object", + "properties": { + "container": { + "$ref": "#/components/schemas/Box" }, - "grantable": { - "type": "boolean" + "products": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContainerProductSummary" + } }, - "lock_reason": { - "type": "string", - "nullable": true + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContainerAssetSummary" + } + }, + "children": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Box" + } } }, "required": [ - "description", - "grantable", - "group", - "label", - "lock_reason", - "value" + "assets", + "children", + "container", + "products" ] }, - "ApiIntegrationSettings": { + "ContainerHistory": { "type": "object", "properties": { - "public_code": { - "type": "string", - "readOnly": true - }, - "public_api_key": { - "type": "string", - "readOnly": true + "container": { + "type": "integer" }, - "cors_allowed_origins": { + "scans": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContainerScanHistoryItem" + } + } + }, + "required": [ + "container", + "scans" + ] + }, + "ContainerMove": { + "type": "object", + "properties": { + "kit_components": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/KitComponent" }, - "readOnly": true - }, - "telegram_group_chat_id": { - "type": "string", - "maxLength": 64 - }, - "telegram_bot_token": { - "type": "string", - "writeOnly": true - }, - "telegram_bot_token_set": { - "type": "boolean", - "readOnly": true - }, - "smtp_host": { - "type": "string", - "maxLength": 200 + "maxItems": 50 }, - "smtp_port": { + "parent_id": { "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "smtp_username": { - "type": "string", - "maxLength": 200 + "nullable": true }, - "smtp_password": { - "type": "string", - "writeOnly": true + "label": { + "type": "string" }, - "smtp_password_set": { - "type": "boolean", - "readOnly": true + "location": { + "type": "string" }, - "smtp_use_tls": { - "type": "boolean" + "description": { + "type": "string" }, - "smtp_use_ssl": { + "is_active": { "type": "boolean" + } + } + }, + "ContainerProductSummary": { + "type": "object", + "properties": { + "id": { + "type": "integer" }, - "smtp_from_email": { - "oneOf": [ - { - "type": "string", - "format": "email", - "maxLength": 254 - }, - { - "type": "string", - "maxLength": 0 - } - ] - }, - "slack_webhook_url": { - "type": "string", - "writeOnly": true, - "maxLength": 2048 - }, - "slack_webhook_url_set": { - "type": "boolean", - "readOnly": true - }, - "mattermost_webhook_url": { - "type": "string", - "writeOnly": true, - "maxLength": 2048 - }, - "mattermost_webhook_url_set": { - "type": "boolean", - "readOnly": true - }, - "discord_webhook_url": { - "type": "string", - "writeOnly": true, - "maxLength": 2048 + "name": { + "type": "string" }, - "discord_webhook_url_set": { - "type": "boolean", - "readOnly": true + "available_quantity": { + "type": "integer" }, - "default_loan_days": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "tracking_mode": { + "type": "string" } }, "required": [ - "cors_allowed_origins", - "discord_webhook_url_set", - "mattermost_webhook_url_set", - "public_api_key", - "public_code", - "slack_webhook_url_set", - "smtp_password_set", - "telegram_bot_token_set" + "available_quantity", + "id", + "name", + "tracking_mode" ] }, - "ApiKeyRequest": { + "ContainerScanHistoryItem": { "type": "object", "properties": { "id": { - "type": "integer", - "readOnly": true - }, - "makerspace": { - "type": "integer" - }, - "label": { - "type": "string", - "maxLength": 120 - }, - "reason": { "type": "string" }, - "allowed_origins": { - "type": "array", - "items": { - "type": "string" - } + "source": { + "type": "string" }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/StatusE94Enum" - } - ], - "readOnly": true + "context": { + "type": "string" }, - "resolution_note": { - "type": "string", - "readOnly": true + "actor": { + "type": "integer", + "nullable": true }, "created_at": { "type": "string", - "format": "date-time", - "readOnly": true - }, - "resolved_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "format": "date-time" } }, "required": [ - "allowed_origins", + "actor", + "context", "created_at", "id", - "label", - "makerspace", - "resolution_note", - "resolved_at", - "status" + "source" ] }, - "ApprovalModeE35Enum": { + "ContentTypeEnum": { "enum": [ - "instant", - "approve" + "image/png", + "image/jpeg" ], "type": "string", - "description": "* `instant` - Instant confirmation\n* `approve` - Staff approval required" + "description": "* `image/png` - image/png\n* `image/jpeg` - image/jpeg" }, - "ApproveRequest": { + "ContextEnum": { + "enum": [ + "issue", + "return", + "inventory_check" + ], + "type": "string", + "description": "* `issue` - issue\n* `return` - return\n* `inventory_check` - inventory_check" + }, + "Count": { "type": "object", "properties": { - "role_id": { + "count": { "type": "integer" - }, - "plan_id": { - "type": "integer", - "nullable": true } }, "required": [ - "role_id" + "count" ] }, - "ArchiveCustodyReadiness": { + "CreateBoxQr": { "type": "object", "properties": { - "below_floor_makerspaces": { - "type": "integer", - "minimum": 0 + "makerspace_id": { + "type": "integer" }, - "zero_recipient_makerspaces": { - "type": "integer", - "minimum": 0 + "label": { + "type": "string" }, - "undelivered_alarms": { - "type": "integer", - "minimum": 0 + "location": { + "type": "string" }, - "alarms_with_no_operator_address": { + "description": { + "type": "string" + }, + "parent_id": { "type": "integer", - "minimum": 0 + "nullable": true } }, "required": [ - "alarms_with_no_operator_address", - "below_floor_makerspaces", - "undelivered_alarms", - "zero_recipient_makerspaces" - ] - }, - "ArchiveCustodyStateEnum": { - "type": "string", - "enum": [ - "healthy", - "not_applicable", - "degraded_one_recipient", - "floor_breached_zero" + "label", + "makerspace_id" ] }, - "ArchiveRecipient": { + "CreateToolQr": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true + "makerspace_id": { + "type": "integer" }, - "public_recipient": { - "type": "string", - "readOnly": true + "product_id": { + "type": "integer" }, - "fingerprint": { - "type": "string", - "readOnly": true + "asset_id": { + "type": "integer" + } + }, + "required": [ + "makerspace_id" + ] + }, + "CreationInviteDelivery": { + "type": "object", + "properties": { + "accept_url": { + "type": "string" }, - "label": { + "delivery_status": { + "type": "string" + } + }, + "required": [ + "accept_url", + "delivery_status" + ] + }, + "CreationProfile": { + "type": "object", + "description": "Explicit fields override a pack; omitted fields keep its configuration.", + "properties": { + "timezone": { "type": "string", - "readOnly": true - }, - "added_by": { - "type": "integer", - "readOnly": true, - "nullable": true + "maxLength": 100 }, - "added_at": { + "country": { "type": "string", - "format": "date-time", - "readOnly": true + "maxLength": 2 }, - "revoked_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "opening_hours": { + "oneOf": [ + { + "type": "object", + "maxProperties": 0 + }, + { + "type": "object", + "required": [ + "weekly", + "exceptions" + ], + "additionalProperties": false, + "properties": { + "weekly": { + "oneOf": [ + { + "type": "object", + "maxProperties": 0 + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ], + "properties": { + "mon": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "tue": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "wed": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "thu": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "fri": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "sat": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "sun": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + } + } + } + ] + }, + "exceptions": { + "type": "object", + "maxProperties": 366, + "additionalProperties": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "description": "YYYY-MM-DD keys; each value replaces that date's weekly hours." + } + } + } + ], + "description": "Empty object means always open. Empty weekly hours mean always open except dated overrides. Empty interval lists mean closed. Sorted intervals use local wall time; split overnight hours across days." }, - "compromised_at": { + "default_currency": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "maxLength": 3 }, - "verified_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "timezone_choices": { + "type": "array", + "items": { + "type": "string" + }, + "readOnly": true }, - "challenge_issued_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "country_choices": { + "type": "array", + "items": { + "type": "string" + }, + "readOnly": true } }, "required": [ - "added_at", - "added_by", - "challenge_issued_at", - "compromised_at", - "fingerprint", - "id", - "label", - "public_recipient", - "revoked_at", - "verified_at" + "country_choices", + "timezone_choices" ] }, - "ArchiveRecipientChallenge": { + "CustodyState": { "type": "object", "properties": { - "recipient": { - "$ref": "#/components/schemas/ArchiveRecipient" + "state": { + "type": "string" }, - "encrypted_challenge": { - "type": "string", - "description": "The binary age ciphertext encoded as unpadded base64url for JSON transport. Its decrypted plaintext is the 32-byte nonce encoded as canonical, unpadded base64url." + "reason_code": { + "type": "string" }, - "nonce_encoding": { - "type": "string", - "default": "base64url-unpadded", - "description": "The decrypted nonce uses canonical unpadded base64url." - } - }, - "required": [ - "encrypted_challenge", - "recipient" - ] - }, - "ArchiveRecipientCreate": { - "type": "object", - "properties": { - "public_recipient": { + "entered_at": { "type": "string", - "maxLength": 200 + "format": "date-time", + "nullable": true }, - "label": { + "cleared_at": { "type": "string", - "maxLength": 120 + "format": "date-time", + "nullable": true } }, "required": [ - "label", - "public_recipient" + "cleared_at", + "entered_at", + "reason_code", + "state" ] }, - "ArchiveRecipientError": { + "CutoverOutcome": { "type": "object", "properties": { - "detail": { + "message": { "type": "string" }, - "code": { - "type": "string" - } - }, - "required": [ - "code", - "detail" - ] - }, - "ArchiveRecipientVerify": { - "type": "object", - "properties": { - "nonce": { - "type": "string", - "description": "The decrypted 32-byte nonce in canonical, unpadded base64url form. Padding and non-canonical encodings are refused.", - "maxLength": 128 + "receipt": { + "$ref": "#/components/schemas/ReceiptEnvelope" } }, "required": [ - "nonce" + "message" ] }, - "ArchiveRequestError": { + "CutoverReceiptRequest": { "type": "object", "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" + "receipt": { + "$ref": "#/components/schemas/ReceiptEnvelope" } }, "required": [ - "detail" + "receipt" ] }, - "ArchiveRequestValidationError": { + "DamagedLostReport": { "type": "object", - "description": "DRF field-keyed errors, which are NOT the `detail`/`code` shape.\n\nA blank or overlong reason fails in `serializer.is_valid(raise_exception=True)` and comes\nback as `{\"reason\": [\"...\"]}`. Declaring every 400 as `ArchiveRequestError` published a\ncontract the endpoint does not honour, so a generated client would destructure `detail`\nand find nothing. Follows the `ProvisionSubdomainValidationErrorSerializer` precedent.", "properties": { - "reason": { + "rows": { "type": "array", "items": { - "type": "string" + "type": "array", + "items": {} } - } - } - }, - "ArchivedPaymentMakerspace": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "slug": { - "type": "string", - "pattern": "^[-a-zA-Z0-9_]+$" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "slug" - ] - }, - "ArchivedPaymentSummary": { - "type": "object", - "properties": { - "makerspace": { - "$ref": "#/components/schemas/ArchivedPaymentMakerspace" - }, - "pending_count": { - "type": "integer", - "minimum": 0 - }, - "total_count": { - "type": "integer", - "minimum": 1 - } - }, - "required": [ - "makerspace", - "pending_count", - "total_count" - ] - }, - "AssetChainGroup": { - "type": "object", - "properties": { - "asset_id": { - "type": "integer", - "nullable": true - }, - "asset_tag": { - "type": "string" }, - "serial_number": { - "type": "string" - }, - "status": { - "type": "string" - }, - "events": { + "typed_rows": { "type": "array", "items": { - "$ref": "#/components/schemas/TimelineEvent" + "$ref": "#/components/schemas/DamagedLostReportRow" } } }, "required": [ - "asset_id", - "asset_tag", - "events", - "serial_number", - "status" + "rows", + "typed_rows" ] }, - "AssetGenerate": { + "DamagedLostReportRow": { "type": "object", "properties": { - "count": { - "type": "integer", - "maximum": 200, - "minimum": 1 + "makerspace_id": { + "type": "integer" }, - "name_prefix": { + "product_id": { + "type": "integer" + }, + "product_name": { "type": "string" }, - "serial_numbers": { - "type": "array", - "items": { - "type": "string" - } + "damaged_quantity": { + "type": "integer" }, - "print_batch_id": { - "type": "integer", - "nullable": true + "lost_quantity": { + "type": "integer" }, - "create_print_batch": { - "type": "boolean", - "default": false - } - }, - "required": [ - "count" - ] - }, - "AssetGenerateItem": { - "type": "object", - "properties": { - "id": { + "total_quantity": { "type": "integer" }, - "asset_tag": { - "type": "string" + "damage_rate_percent": { + "type": "number", + "format": "double", + "nullable": true }, - "qr": { - "$ref": "#/components/schemas/QrCode" + "loss_rate_percent": { + "type": "number", + "format": "double", + "nullable": true } }, "required": [ - "asset_tag", - "id", - "qr" + "damage_rate_percent", + "damaged_quantity", + "loss_rate_percent", + "lost_quantity", + "product_id", + "product_name", + "total_quantity" ] }, - "AssetGenerateResult": { + "DamagedMissingReport": { "type": "object", "properties": { - "assets": { + "rows": { "type": "array", "items": { - "$ref": "#/components/schemas/AssetGenerateItem" + "type": "array", + "items": {} } }, - "print_batch_id": { - "type": "integer", - "nullable": true - } - }, - "required": [ - "assets", - "print_batch_id" - ] - }, - "AssetQrHistory": { - "type": "object", - "properties": { - "asset": { - "type": "integer" - }, - "scans": { + "typed_rows": { "type": "array", "items": { - "$ref": "#/components/schemas/QrHistoryItem" + "$ref": "#/components/schemas/DamagedMissingReportRow" } } }, "required": [ - "asset", - "scans" - ] - }, - "AssignBox": { - "type": "object", - "properties": { - "box_code": { - "type": "string" - } - }, - "required": [ - "box_code" - ] - }, - "AssignOperator": { - "type": "object", - "properties": { - "user_id": { - "type": "integer" - }, - "access_level": { - "type": "string", - "maxLength": 16 - } - }, - "required": [ - "access_level", - "user_id" + "rows", + "typed_rows" ] }, - "AttendanceCorrectionResponse": { + "DamagedMissingReportRow": { "type": "object", "properties": { - "registration_id": { + "makerspace_id": { "type": "integer" }, - "status": { + "product": { "type": "string" }, - "revoked_certificates": { - "type": "integer" - } - }, - "required": [ - "registration_id", - "revoked_certificates", - "status" - ] - }, - "AttendedEvent": { - "type": "object", - "properties": { - "id": { + "damaged_quantity": { "type": "integer" }, - "title": { - "type": "string" - }, - "starts_at": { - "type": "string", - "format": "date-time" + "missing_quantity": { + "type": "integer" } }, "required": [ - "id", - "starts_at", - "title" + "damaged_quantity", + "missing_quantity", + "product" ] }, - "AudienceBe5Enum": { - "enum": [ - "member", - "public" - ], - "type": "string", - "description": "* `member` - Active members\n* `public` - All accounts" - }, - "AuditLog": { + "Dashboard": { "type": "object", "properties": { - "id": { + "upcoming_events": { "type": "integer", - "readOnly": true + "default": 0 }, - "actor": { + "pending_event_registrations": { "type": "integer", - "readOnly": true, - "nullable": true - }, - "action": { - "type": "string", - "readOnly": true + "default": 0 }, - "makerspace": { + "pending_bookings": { "type": "integer", - "readOnly": true, - "nullable": true - }, - "target_type": { - "type": "string", - "readOnly": true - }, - "target_id": { - "type": "string", - "readOnly": true - }, - "meta": { - "type": "object", - "additionalProperties": {}, - "readOnly": true - }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "action_label": { - "type": "string", - "readOnly": true - }, - "actor_display": { - "type": "string", - "nullable": true, - "readOnly": true - }, - "meta_redacted": { - "type": "boolean", - "readOnly": true - }, - "target_link": { - "allOf": [ - { - "$ref": "#/components/schemas/AuditTargetLink" - } - ], - "nullable": true, - "readOnly": true - } - }, - "required": [ - "action", - "action_label", - "actor", - "actor_display", - "created_at", - "id", - "makerspace", - "meta", - "meta_redacted", - "target_id", - "target_link", - "target_type" - ] - }, - "AuditTargetLink": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "label", - "path" - ] - }, - "AuditVerificationBadRequest": { - "oneOf": [ - { - "$ref": "#/components/schemas/AuditVerificationError" - }, - { - "$ref": "#/components/schemas/AuditVerificationFilterError" - } - ] - }, - "AuditVerificationDetailError": { - "type": "object", - "properties": { - "detail": { - "type": "string" - } - }, - "required": [ - "detail" - ] - }, - "AuditVerificationError": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "detail": { - "type": "string" - } - }, - "required": [ - "code", - "detail" - ] - }, - "AuditVerificationFilterError": { - "type": "object", - "properties": { - "action": { - "type": "array", - "items": { - "type": "string" - } - }, - "target_type": { - "type": "array", - "items": { - "type": "string" - } - }, - "target_id": { - "type": "array", - "items": { - "type": "string" - } + "default": 0 }, - "actor": { - "type": "array", - "items": { - "type": "string" - } + "upcoming_bookings": { + "type": "integer", + "default": 0 }, - "start": { - "type": "array", - "items": { - "type": "string" - } + "scope_mode": { + "$ref": "#/components/schemas/ScopeModeEnum" }, - "end": { + "widgets": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/DashboardWidget" } }, - "non_field_errors": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "AuthMembership": { - "type": "object", - "properties": { - "id": { - "type": "integer" + "queue_tiles": { + "$ref": "#/components/schemas/DashboardQueue" }, - "slug": { - "type": "string" + "overdue_loans": { + "type": "integer", + "default": 0 }, - "role": { - "type": "string", - "nullable": true + "pending_requests": { + "type": "integer", + "default": 0 }, - "role_id": { + "awaiting_issue": { "type": "integer", - "nullable": true + "default": 0 }, - "role_name": { - "type": "string" + "open_problem_reports": { + "type": "integer", + "default": 0 }, - "role_slug": { - "type": "string", - "nullable": true + "low_stock": { + "type": "integer", + "default": 0 }, - "source": { - "$ref": "#/components/schemas/AuthMembershipSourceEnum" + "pending_prints": { + "type": "integer", + "default": 0 }, - "actions": { - "type": "array", - "items": { - "type": "string" - } + "active_prints": { + "type": "integer", + "default": 0 }, - "can_configure_machine_types": { - "type": "boolean" + "prints_awaiting_collection": { + "type": "integer", + "default": 0 }, - "is_machine_only": { - "type": "boolean" + "failed_emails": { + "type": "integer", + "default": 0 }, - "can_refer": { - "type": "boolean" + "stocktakes_awaiting_approval": { + "type": "integer", + "default": 0 }, - "can_verify": { - "type": "boolean" + "warranty_expiring": { + "type": "integer", + "default": 0 }, - "verified_at": { - "type": "string", - "format": "date-time", - "nullable": true + "maintenance_overdue": { + "type": "integer", + "default": 0 }, - "referrals_enabled": { - "type": "boolean" + "pending_payments": { + "type": "integer", + "default": 0 + }, + "outstanding_by_currency": { + "type": "object", + "additionalProperties": { + "type": "string" + } } }, "required": [ - "actions", - "can_configure_machine_types", - "can_refer", - "can_verify", - "id", - "is_machine_only", - "referrals_enabled", - "role", - "role_id", - "role_name", - "role_slug", - "slug", - "source", - "verified_at" + "scope_mode" ] }, - "AuthMembershipSourceEnum": { - "enum": [ - "membership", - "organization" - ], - "type": "string", - "description": "* `membership` - membership\n* `organization` - organization" - }, - "AuthUserPayload": { + "DashboardQueue": { "type": "object", "properties": { - "id": { + "ready_to_issue": { "type": "integer" }, - "username": { - "type": "string" - }, - "email": { - "type": "string", - "format": "email" + "due_returns": { + "type": "integer" }, - "display_name": { + "pickups": { + "type": "integer" + } + } + }, + "DashboardWidget": { + "type": "object", + "properties": { + "key": { "type": "string" }, - "phone": { + "title": { "type": "string" }, - "email_verified": { - "type": "boolean" - }, - "role": { + "required_action": { "type": "string" }, - "is_superuser": { - "type": "boolean" + "required_modules": { + "type": "array", + "items": { + "type": "string" + } }, - "must_change_password": { - "type": "boolean" + "kind": { + "$ref": "#/components/schemas/DashboardWidgetKindEnum" }, - "makerspaces": { + "report_keys": { "type": "array", "items": { - "$ref": "#/components/schemas/AuthMembership" + "type": "string" } - } - }, - "required": [ - "display_name", - "email", - "email_verified", - "id", - "is_superuser", - "makerspaces", - "must_change_password", - "phone", - "role", - "username" - ] - }, - "AvailabilityEnum": { - "type": "string", - "enum": [ - "Available", - "Limited", - "Full" - ] - }, - "BackupArchive": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "readOnly": true - }, - "scope": { - "allOf": [ - { - "$ref": "#/components/schemas/ScopeEnum" - } - ], - "readOnly": true }, - "makerspace": { + "default_period": { "type": "integer", - "readOnly": true, - "nullable": true + "description": "Inclusive local calendar days." }, - "status": { + "data": { + "type": "object", + "additionalProperties": {} + }, + "chart_spec": { "allOf": [ { - "$ref": "#/components/schemas/BackupArchiveStatusEnum" + "$ref": "#/components/schemas/ReportChartSpec" } ], - "readOnly": true - }, - "manifest": { - "readOnly": true - }, - "size_bytes": { - "type": "integer", - "readOnly": true - }, - "age_encrypted": { - "type": "boolean", - "readOnly": true - }, - "failure_detail": { - "type": "string", - "readOnly": true - }, - "started_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "completed_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "expires_at": { - "type": "string", - "format": "date-time", - "readOnly": true, "nullable": true - }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "purge_warning": { - "type": "string", - "readOnly": true } }, "required": [ - "age_encrypted", - "completed_at", - "created_at", - "expires_at", - "failure_detail", - "id", - "makerspace", - "manifest", - "purge_warning", - "scope", - "size_bytes", - "started_at", - "status" + "chart_spec", + "data", + "default_period", + "key", + "kind", + "report_keys", + "required_action", + "required_modules", + "title" ] }, - "BackupArchiveStatusEnum": { + "DashboardWidgetKindEnum": { "enum": [ - "pending", - "running", - "promoting", - "available", - "failed", - "expired" + "count", + "trend", + "top_n", + "heatmap", + "queue" ], "type": "string", - "description": "* `pending` - Pending\n* `running` - Running\n* `promoting` - Promoting\n* `available` - Available\n* `failed` - Failed\n* `expired` - Expired" + "description": "* `count` - count\n* `trend` - trend\n* `top_n` - top_n\n* `heatmap` - heatmap\n* `queue` - queue" }, - "BackupCustody": { + "DataExportCreate": { "type": "object", "properties": { - "superadmin_access_enabled": { - "type": "boolean" - }, - "verified_recipient_count": { - "type": "integer" - }, - "archive_custody": { + "fidelity": { "allOf": [ { - "$ref": "#/components/schemas/CustodyState" + "$ref": "#/components/schemas/FidelityEnum" } ], - "nullable": true + "default": "REDACTED" + } + } + }, + "DataExportDownloadUrl": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" }, - "tenant_exit_custody": { - "allOf": [ - { - "$ref": "#/components/schemas/CustodyState" - } - ], - "nullable": true + "expires_at": { + "type": "string", + "format": "date-time" } }, "required": [ - "archive_custody", - "superadmin_access_enabled", - "tenant_exit_custody", - "verified_recipient_count" + "expires_at", + "url" ] }, - "BackupDestination": { + "DataExportJob": { "type": "object", - "description": "Credentials are write-only; reads report only whether they are stored.", "properties": { "id": { - "type": "integer", + "type": "string", + "format": "uuid", "readOnly": true }, - "name": { + "fidelity": { "type": "string", - "maxLength": 64, - "pattern": "^[-a-zA-Z0-9_]+$" + "readOnly": true }, - "kind": { + "status": { "allOf": [ { - "$ref": "#/components/schemas/BackupDestinationKindEnum" + "$ref": "#/components/schemas/StatusE1dEnum" } ], "readOnly": true }, - "endpoint_url": { + "manifest": { + "readOnly": true + }, + "failure_code": { + "readOnly": true, "oneOf": [ { - "type": "string", - "format": "uri", - "maxLength": 300 + "$ref": "#/components/schemas/FailureCodeEnum" }, { - "type": "string", - "maxLength": 0 + "$ref": "#/components/schemas/BlankEnum" } ] }, - "bucket": { - "type": "string", - "maxLength": 255 - }, - "region": { - "type": "string", - "maxLength": 64 - }, - "prefix": { - "type": "string", - "maxLength": 200 - }, - "addressing_style": { - "$ref": "#/components/schemas/AddressingStyleEnum" - }, - "access_key_id": { - "type": "string", - "writeOnly": true, - "maxLength": 200 - }, - "secret_access_key": { + "failure_detail": { "type": "string", - "writeOnly": true, - "maxLength": 200 - }, - "credentials_set": { - "type": "boolean", "readOnly": true }, - "enabled": { - "type": "boolean" - }, - "retention_days": { - "type": "integer", - "maximum": 3650, - "minimum": 1 - }, - "created_at": { + "deadline_at": { "type": "string", "format": "date-time", - "readOnly": true + "readOnly": true, + "nullable": true }, - "updated_at": { + "snapshot_at": { "type": "string", "format": "date-time", - "readOnly": true + "readOnly": true, + "nullable": true }, - "retired_at": { + "started_at": { "type": "string", "format": "date-time", "readOnly": true, "nullable": true }, - "last_success_at": { + "completed_at": { "type": "string", "format": "date-time", "readOnly": true, "nullable": true }, - "last_error_code": { + "expires_at": { "type": "string", + "format": "date-time", "readOnly": true - } - }, - "required": [ - "bucket", - "created_at", - "credentials_set", - "id", - "kind", - "last_error_code", - "last_success_at", - "name", - "retired_at", - "updated_at" - ] - }, - "BackupDestinationKindEnum": { - "enum": [ - "s3" - ], - "type": "string", - "description": "* `s3` - S3-compatible (AWS, R2, Supabase Storage, MinIO)" - }, - "BackupDestinationPage": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "next": { - "type": "string", - "format": "uri", - "nullable": true }, - "previous": { + "created_at": { "type": "string", - "format": "uri", - "nullable": true - }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BackupDestination" - } + "format": "date-time", + "readOnly": true } }, "required": [ - "count", - "next", - "previous", - "results" + "completed_at", + "created_at", + "deadline_at", + "expires_at", + "failure_code", + "failure_detail", + "fidelity", + "id", + "manifest", + "snapshot_at", + "started_at", + "status" ] }, - "BackupDestinationUpload": { + "Delivery": { "type": "object", "properties": { "id": { - "type": "integer", - "readOnly": true - }, - "destination": { - "type": "string", - "readOnly": true - }, - "archive_id_snapshot": { - "type": "string", - "format": "uuid", - "readOnly": true - }, - "scope_snapshot": { - "type": "string", - "readOnly": true - }, - "makerspace_id_snapshot": { - "type": "integer", - "readOnly": true, - "nullable": true - }, - "archive_completed_at_snapshot": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "expected_size_bytes": { - "type": "integer", - "readOnly": true - }, - "expected_sha256": { - "type": "string", - "readOnly": true + "type": "integer" }, - "integrity_source": { - "allOf": [ - { - "$ref": "#/components/schemas/IntegritySourceEnum" - } - ], - "readOnly": true + "kind": { + "$ref": "#/components/schemas/DeliveryKindEnum" }, - "remote_locator": { - "type": "string", - "readOnly": true + "channel": { + "type": "string" }, - "state": { - "allOf": [ - { - "$ref": "#/components/schemas/BackupDestinationUploadStateEnum" - } - ], - "readOnly": true + "feature": { + "type": "string" }, - "attempts": { + "event": { + "type": "string" + }, + "destination_id": { "type": "integer", - "readOnly": true + "nullable": true }, - "next_attempt_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "destination_label": { + "type": "string" }, - "uploaded_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "status": { + "type": "string" }, - "verified_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "error": { + "type": "string" }, - "expires_at": { + "attempts": { + "type": "integer" + }, + "retry_generation": { + "type": "integer" + }, + "created_at": { "type": "string", - "format": "date-time", - "readOnly": true + "format": "date-time" }, - "deleted_at": { + "sent_at": { "type": "string", "format": "date-time", - "readOnly": true, "nullable": true - }, - "last_error_code": { - "type": "string", - "readOnly": true } }, "required": [ - "archive_completed_at_snapshot", - "archive_id_snapshot", "attempts", - "deleted_at", - "destination", - "expected_sha256", - "expected_size_bytes", - "expires_at", + "channel", + "created_at", + "destination_id", + "destination_label", + "error", + "event", + "feature", "id", - "integrity_source", - "last_error_code", - "makerspace_id_snapshot", - "next_attempt_at", - "remote_locator", - "scope_snapshot", - "state", - "uploaded_at", - "verified_at" + "kind", + "retry_generation", + "sent_at", + "status" ] }, - "BackupDestinationUploadPage": { + "DeliveryEnum": { + "enum": [ + "web", + "device" + ], + "type": "string", + "description": "* `web` - Web\n* `device` - Device" + }, + "DeliveryKindEnum": { + "enum": [ + "email", + "channel" + ], + "type": "string", + "description": "* `email` - email\n* `channel` - channel" + }, + "DeliveryPage": { "type": "object", "properties": { "count": { @@ -70141,7 +91518,7 @@ "results": { "type": "array", "items": { - "$ref": "#/components/schemas/BackupDestinationUpload" + "$ref": "#/components/schemas/Delivery" } } }, @@ -70152,741 +91529,877 @@ "results" ] }, - "BackupDestinationUploadStateEnum": { - "enum": [ - "pending", - "uploading", - "bytes_verified", - "abandoned", - "deleted" - ], - "type": "string", - "description": "* `pending` - Pending upload\n* `uploading` - Uploading\n* `bytes_verified` - Bytes verified\n* `abandoned` - Abandoned (source no longer available)\n* `deleted` - Deleted by retention" + "DeliveryTest": { + "type": "object", + "properties": { + "destination_id": { + "type": "integer", + "minimum": 1 + } + } }, - "BackupDownload": { + "DeliveryTestResult": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" + "kind": { + "type": "string" }, - "expires_at": { - "type": "string", - "format": "date-time" + "id": { + "type": "integer" }, - "purge_warning": { + "status": { "type": "string" } }, "required": [ - "expires_at", - "purge_warning", - "url" + "id", + "kind", + "status" ] }, - "BadgePdfRequest": { + "DeploymentIdentity": { "type": "object", "properties": { - "registration_ids": { - "type": "array", - "items": { - "type": "integer", - "minimum": 1 - }, - "maxItems": 200 + "algorithm": { + "type": "string" }, - "template_override": { - "allOf": [ - { - "$ref": "#/components/schemas/BadgeTemplate" - } - ], - "nullable": true + "deployment_id": { + "type": "string" }, - "include_attended": { - "type": "boolean", - "default": false + "public_key": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "age_recipient": { + "type": "string" } }, "required": [ - "registration_ids" + "age_recipient", + "algorithm", + "deployment_id", + "fingerprint", + "public_key" ] }, - "BadgeTemplate": { + "DestinationDispatch": { "type": "object", "properties": { - "version": { - "type": "integer" - }, - "paper_size": { - "$ref": "#/components/schemas/PaperSizeEnum" - }, - "orientation": { - "$ref": "#/components/schemas/OrientationEnum" - }, - "page_width_mm": { - "type": "number", - "format": "double", - "nullable": true - }, - "page_height_mm": { - "type": "number", - "format": "double", - "nullable": true - }, - "card_width_mm": { - "type": "number", - "format": "double" - }, - "card_height_mm": { - "type": "number", - "format": "double" - }, - "margin_mm": { - "type": "number", - "format": "double" - }, - "gap_mm": { - "type": "number", - "format": "double" - }, - "template": { + "detail": { "type": "string" - }, - "fields": { - "type": "array", - "items": { - "type": "string", - "maxLength": 80 - } - }, - "font_size_pt": { - "type": "number", - "format": "double" - }, - "name_font_size_pt": { - "type": "number", - "format": "double" - }, - "text_align": { - "$ref": "#/components/schemas/TextAlignEnum" - }, - "include_qr": { - "type": "boolean" } - } + }, + "required": [ + "detail" + ] }, - "BillingPolicyError": { - "oneOf": [ - { - "$ref": "#/components/schemas/HardwareRequestError" + "DestinationProbe": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" }, - { - "$ref": "#/components/schemas/BillingPolicyValidationError" + "error_code": { + "type": "string" } + }, + "required": [ + "error_code", + "ok" ] }, - "BillingPolicyValidationError": { + "DestinationScope": { "type": "object", "properties": { - "number_prefix": { - "type": "array", - "items": { - "type": "string" - } - }, - "legal_name": { - "type": "array", - "items": { - "type": "string" - } - }, - "address": { - "type": "array", - "items": { - "type": "string" - } - }, - "tax_id": { - "type": "array", - "items": { - "type": "string" - } - }, - "footer": { - "type": "array", - "items": { - "type": "string" - } - }, - "account_codes": {}, - "payment_terms_days": { + "machine_type_ids": { "type": "array", "items": { - "type": "string" + "type": "integer" } }, - "reminder_days_before": {}, - "reminder_days_after": {}, - "max_reminders": { + "machine_ids": { "type": "array", "items": { - "type": "string" + "type": "integer" } }, - "non_field_errors": { + "category_ids": { "type": "array", "items": { - "type": "string" + "type": "integer" } } } }, - "BillingSettings": { + "Device": { "type": "object", "properties": { - "payment_terms_days": { + "id": { + "type": "string", + "format": "uuid", + "readOnly": true + }, + "makerspace": { "type": "integer", - "maximum": 365, - "minimum": 0 + "readOnly": true }, - "reminder_days_before": { - "type": "array", - "items": { - "type": "integer", - "maximum": 365, - "minimum": 1 - }, - "maxItems": 20 + "kind": { + "allOf": [ + { + "$ref": "#/components/schemas/KindE45Enum" + } + ], + "readOnly": true }, - "reminder_days_after": { - "type": "array", - "items": { - "type": "integer", - "maximum": 365, - "minimum": 1 - }, - "maxItems": 20 + "name": { + "type": "string", + "readOnly": true }, - "max_reminders": { + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/DeviceStatusEnum" + } + ], + "readOnly": true + }, + "public_key": { + "type": "string", + "readOnly": true, + "nullable": true + }, + "key_fingerprint": { + "type": "string", + "readOnly": true, + "nullable": true + }, + "key_version": { "type": "integer", - "maximum": 20, - "minimum": 0 + "readOnly": true }, - "number_prefix": { + "last_seen_at": { "type": "string", - "maxLength": 32 + "format": "date-time", + "readOnly": true, + "nullable": true }, - "legal_name": { + "offline_threshold_seconds": { + "type": "integer", + "readOnly": true + }, + "offline_since": { "type": "string", - "maxLength": 150 + "format": "date-time", + "readOnly": true, + "nullable": true }, - "address": { + "offline_notified_at": { "type": "string", - "maxLength": 1000 + "format": "date-time", + "readOnly": true, + "nullable": true }, - "tax_id": { + "created_by": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { "type": "string", - "maxLength": 64 + "format": "date-time", + "readOnly": true }, - "footer": { + "revoked_at": { "type": "string", - "maxLength": 1000 + "format": "date-time", + "readOnly": true, + "nullable": true }, - "account_codes": {} + "revoked_by": { + "type": "integer", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "created_at", + "created_by", + "id", + "key_fingerprint", + "key_version", + "kind", + "last_seen_at", + "makerspace", + "name", + "offline_notified_at", + "offline_since", + "offline_threshold_seconds", + "public_key", + "revoked_at", + "revoked_by", + "status" + ] + }, + "DeviceAttestationPayload": { + "anyOf": [ + { + "type": "object", + "required": [ + "type", + "token" + ], + "properties": { + "type": { + "$ref": "#/components/schemas/DeviceAttestationPayloadTypeEnum" + }, + "token": { + "type": "string", + "maxLength": 65536 + } + } + }, + { + "type": "object", + "required": [ + "type", + "key_id", + "attestation" + ], + "properties": { + "type": { + "$ref": "#/components/schemas/DeviceAttestationPayloadTypeEnum" + }, + "key_id": { + "type": "string", + "format": "byte", + "maxLength": 64 + }, + "attestation": { + "type": "string", + "format": "byte", + "maxLength": 65536 + } + } + }, + { + "type": "object", + "required": [ + "type" + ], + "additionalProperties": false, + "properties": { + "type": { + "$ref": "#/components/schemas/DeviceAttestationPayloadTypeEnum" + } + }, + "description": "Only with development-insecure mode and a development registration." + }, + { + "type": "object", + "description": "Deployment-specific external verifier proof." + } + ] + }, + "DeviceAttestationPayloadTypeEnum": { + "type": "string", + "enum": [ + "play_integrity" + ] + }, + "DeviceChallengeResponse": { + "type": "object", + "properties": { + "challenge": { + "type": "string" + }, + "expires_in": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "challenge", + "expires_in" + ] + }, + "DeviceCreate": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/components/schemas/KindE45Enum" + }, + "name": { + "type": "string", + "maxLength": 120 + }, + "offline_threshold_seconds": { + "type": "integer", + "maximum": 604800, + "minimum": 1, + "default": 300 + } + }, + "required": [ + "kind", + "name" + ] + }, + "DeviceCreated": { + "type": "object", + "properties": { + "device": { + "$ref": "#/components/schemas/Device" + }, + "enrollment": { + "$ref": "#/components/schemas/EnrollmentGrant" + } }, "required": [ - "max_reminders", - "reminder_days_after", - "reminder_days_before" + "device", + "enrollment" ] }, - "Binding": { + "DeviceError": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + }, + "required": [ + "detail" + ] + }, + "DeviceGrant": { "type": "object", "properties": { "id": { "type": "string", - "format": "uuid", - "readOnly": true + "format": "uuid" }, - "makerspace": { - "type": "integer", - "readOnly": true + "platform": { + "type": "string" }, - "device": { - "type": "string", - "format": "uuid", - "readOnly": true + "app_id": { + "type": "string" }, - "machine": { - "type": "integer", - "readOnly": true, - "nullable": true + "environment": { + "type": "string" }, - "enabled": { - "type": "boolean", - "readOnly": true + "status": { + "type": "string" }, - "auto_apply_terminal": { - "type": "boolean", - "readOnly": true + "attested_at": { + "type": "string", + "format": "date-time" }, - "created_at": { + "last_used_at": { "type": "string", - "format": "date-time", - "readOnly": true + "format": "date-time" }, - "updated_at": { + "created_at": { "type": "string", - "format": "date-time", - "readOnly": true + "format": "date-time" } }, "required": [ - "auto_apply_terminal", + "app_id", + "attested_at", "created_at", - "device", - "enabled", + "environment", "id", - "machine", - "makerspace", - "updated_at" + "last_used_at", + "platform", + "status" ] }, - "BindingCreate": { + "DeviceIdentity": { "type": "object", "properties": { - "device_id": { - "type": "string", - "format": "uuid" - }, - "machine_id": { - "type": "integer", - "minimum": 1 + "platform": { + "$ref": "#/components/schemas/PlatformEnum" }, - "enabled": { - "type": "boolean", - "default": true + "app_id": { + "type": "string", + "maxLength": 255, + "pattern": "^[A-Za-z0-9._-]{3,255}$" }, - "auto_apply_terminal": { - "type": "boolean", - "default": false + "environment": { + "$ref": "#/components/schemas/EnvironmentEnum" } }, "required": [ - "device_id", - "machine_id" - ] - }, - "BlankEnum": { - "enum": [ - "" + "app_id", + "environment", + "platform" ] }, - "BookableSpaceAdmin": { + "DeviceLogin": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true + "platform": { + "$ref": "#/components/schemas/PlatformEnum" }, - "public_token": { + "app_id": { "type": "string", - "format": "uuid", - "readOnly": true + "maxLength": 255, + "pattern": "^[A-Za-z0-9._-]{3,255}$" }, - "makerspace_id": { - "type": "integer", - "readOnly": true + "environment": { + "$ref": "#/components/schemas/EnvironmentEnum" }, - "name": { + "username": { "type": "string", - "readOnly": true + "maxLength": 254 }, - "kind": { - "allOf": [ - { - "$ref": "#/components/schemas/Kind3bfEnum" - } - ], - "readOnly": true + "password": { + "type": "string", + "writeOnly": true, + "maxLength": 1024 }, - "description": { + "challenge": { "type": "string", - "readOnly": true + "maxLength": 512 }, - "capacity": { - "type": "integer", - "readOnly": true + "attestation": { + "$ref": "#/components/schemas/DeviceAttestationPayload" + } + }, + "required": [ + "app_id", + "attestation", + "challenge", + "environment", + "password", + "platform", + "username" + ] + }, + "DeviceLoginResult": { + "oneOf": [ + { + "$ref": "#/components/schemas/DeviceTokenResponse" }, - "location": { + { + "$ref": "#/components/schemas/MFAChallengeResponse" + } + ] + }, + "DeviceLogoutResponse": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + }, + "required": [ + "detail" + ] + }, + "DeviceRefresh": { + "type": "object", + "properties": { + "refresh": { "type": "string", - "readOnly": true + "writeOnly": true, + "maxLength": 4096 + } + }, + "required": [ + "refresh" + ] + }, + "DeviceRefreshResponse": { + "type": "object", + "properties": { + "access": { + "type": "string" }, - "image_url": { - "type": "string", - "readOnly": true + "refresh": { + "type": "string" }, - "is_public": { - "type": "boolean", - "readOnly": true + "device_grant": { + "$ref": "#/components/schemas/DeviceGrant" + } + }, + "required": [ + "access", + "device_grant", + "refresh" + ] + }, + "DeviceStatusEnum": { + "enum": [ + "pending", + "active", + "revoked" + ], + "type": "string", + "description": "* `pending` - Pending enrollment\n* `active` - Active\n* `revoked` - Revoked" + }, + "DeviceTokenResponse": { + "type": "object", + "properties": { + "access": { + "type": "string" }, - "show_public_availability": { - "type": "boolean", - "readOnly": true + "refresh": { + "type": "string" }, - "show_public_booker_names": { + "user": { + "type": "object", + "additionalProperties": {} + }, + "device_grant": { + "$ref": "#/components/schemas/DeviceGrant" + } + }, + "required": [ + "access", + "device_grant", + "refresh", + "user" + ] + }, + "DirectLoan": { + "type": "object", + "properties": { + "has_kit_checklist": { "type": "boolean", "readOnly": true }, - "approval_mode": { - "allOf": [ - { - "$ref": "#/components/schemas/ApprovalModeE35Enum" - } - ], + "public_token": { + "type": "string", + "format": "uuid", "readOnly": true }, - "min_booking_duration_minutes": { - "type": "integer", + "status": { + "type": "string", "readOnly": true }, - "max_booking_duration_minutes": { - "type": "integer", + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicToolLoanItem" + }, "readOnly": true }, - "booking_lead_time_minutes": { + "id": { "type": "integer", "readOnly": true }, - "max_booking_advance_days": { - "type": "integer", + "target_type": { + "type": "string", "readOnly": true }, - "buffer_before_minutes": { - "type": "integer", + "target_label": { + "type": "string", "readOnly": true }, - "buffer_after_minutes": { + "container_id": { "type": "integer", "readOnly": true }, - "cancellation_cutoff_hours": { - "type": "integer", + "container_label": { + "type": "string", + "nullable": true, "readOnly": true }, - "custom_form": { + "due_at": { + "type": "string", + "format": "date-time", "readOnly": true, "nullable": true }, - "requester_notifications_enabled": { - "type": "boolean", + "issue_evidence_id": { + "type": "integer", "readOnly": true, "nullable": true }, - "payment_amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "readOnly": true - }, - "machine_type": { + "return_evidence_id": { "type": "integer", "readOnly": true, "nullable": true }, - "effective_requester_notifications_enabled": { - "type": "boolean", + "return_notes": { + "type": "string", "readOnly": true }, - "is_active": { + "return_scan_required": { "type": "boolean", "readOnly": true }, - "created_by_id": { - "type": "integer", - "readOnly": true, - "nullable": true - }, - "created_at": { + "source": { "type": "string", - "format": "date-time", "readOnly": true }, - "updated_at": { - "type": "string", - "format": "date-time", + "issued_by": { + "allOf": [ + { + "$ref": "#/components/schemas/DirectLoanUserAttribution" + } + ], + "nullable": true, + "readOnly": true + }, + "return_items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DirectLoanReturnItem" + }, "readOnly": true } }, "required": [ - "approval_mode", - "booking_lead_time_minutes", - "buffer_after_minutes", - "buffer_before_minutes", - "cancellation_cutoff_hours", - "capacity", - "created_at", - "created_by_id", - "custom_form", - "description", - "effective_requester_notifications_enabled", + "container_id", + "container_label", + "due_at", + "has_kit_checklist", "id", - "image_url", - "is_active", - "is_public", - "kind", - "location", - "machine_type", - "makerspace_id", - "max_booking_advance_days", - "max_booking_duration_minutes", - "min_booking_duration_minutes", - "name", - "payment_amount", + "issue_evidence_id", + "issued_by", + "items", "public_token", - "requester_notifications_enabled", - "show_public_availability", - "show_public_booker_names", - "updated_at" + "return_evidence_id", + "return_items", + "return_notes", + "return_scan_required", + "source", + "status", + "target_label", + "target_type" ] }, - "BookableSpaceBookingRules": { + "DirectLoanIssue": { "type": "object", "properties": { - "min_booking_duration_minutes": { - "type": "integer", - "maximum": 2147483647, - "minimum": 1 - }, - "max_booking_duration_minutes": { - "type": "integer", - "maximum": 2147483647, - "minimum": 1 - }, - "booking_lead_time_minutes": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "max_booking_advance_days": { - "type": "integer", - "maximum": 2147483647, - "minimum": 1 - }, - "buffer_before_minutes": { - "type": "integer", - "maximum": 1440, - "minimum": 0 - }, - "buffer_after_minutes": { - "type": "integer", - "maximum": 1440, - "minimum": 0 + "kit_checklist": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KitCount" + }, + "maxItems": 3550 }, - "cancellation_cutoff_hours": { - "type": "integer", - "maximum": 8760, - "minimum": 0 + "borrower_id": { + "type": "integer" }, - "approval_mode": { - "$ref": "#/components/schemas/ApprovalModeE35Enum" - } - } - }, - "BookableSpaceListResponse": { - "type": "object", - "properties": { - "count": { + "evidence_id": { "type": "integer" }, - "next": { - "type": "string", - "nullable": true + "remark": { + "type": "string" }, - "previous": { - "type": "string", + "container_id": { + "type": "integer", "nullable": true }, - "results": { + "qr_payloads": { "type": "array", "items": { - "$ref": "#/components/schemas/BookableSpaceAdmin" - } - } - }, - "required": [ - "count", - "results" - ] - }, - "BookableSpaceWrite": { - "type": "object", - "properties": { - "name": { - "type": "string", - "maxLength": 200 - }, - "kind": { - "allOf": [ - { - "$ref": "#/components/schemas/Kind3bfEnum" - } - ], - "default": "other" - }, - "description": { - "type": "string", - "default": "" - }, - "capacity": { - "type": "integer", - "minimum": 0, - "default": 0 - }, - "location": { - "type": "string", - "default": "", - "maxLength": 255 - }, - "is_public": { - "type": "boolean", - "default": false - }, - "show_public_availability": { - "type": "boolean", - "default": false - }, - "show_public_booker_names": { - "type": "boolean", - "default": false - }, - "custom_form": { - "nullable": true - }, - "requester_notifications_enabled": { - "type": "boolean", - "nullable": true + "type": "string", + "maxLength": 64 + }, + "maxItems": 50 }, - "payment_amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "default": "0.00" + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DirectLoanItem" + } + } + }, + "required": [ + "borrower_id", + "evidence_id" + ] + }, + "DirectLoanItem": { + "type": "object", + "properties": { + "product_id": { + "type": "integer" }, - "machine_type": { + "quantity": { "type": "integer", - "minimum": 1, - "nullable": true + "maximum": 2147483647, + "minimum": 1 } }, "required": [ - "name" + "product_id", + "quantity" ] }, - "BookingAdmin": { + "DirectLoanMember": { "type": "object", "properties": { - "id": { + "membership_id": { "type": "integer", "readOnly": true }, - "public_token": { - "type": "string", - "format": "uuid", - "readOnly": true - }, - "space_id": { + "user_id": { "type": "integer", "readOnly": true }, - "name": { + "display_name": { "type": "string", "readOnly": true }, - "email": { + "username": { "type": "string", "readOnly": true }, - "phone": { - "type": "string", + "is_walk_in": { + "type": "boolean", "readOnly": true + } + }, + "required": [ + "display_name", + "is_walk_in", + "membership_id", + "user_id", + "username" + ] + }, + "DirectLoanReturn": { + "type": "object", + "properties": { + "kit_checklist": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KitCount" + }, + "maxItems": 3550 }, - "starts_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "evidence_id": { + "type": "integer" }, - "ends_at": { + "notes": { + "type": "string" + }, + "qr_payload": { "type": "string", - "format": "date-time", - "readOnly": true + "maxLength": 64 }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/Status2caEnum" - } - ], - "readOnly": true + "resolutions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReturnItemResolution" + } + } + }, + "required": [ + "evidence_id", + "notes" + ] + }, + "DirectLoanReturnAsset": { + "type": "object", + "properties": { + "asset_id": { + "type": "integer" }, - "note": { - "type": "string", - "readOnly": true + "asset_tag": { + "type": "string" + } + }, + "required": [ + "asset_id", + "asset_tag" + ] + }, + "DirectLoanReturnItem": { + "type": "object", + "properties": { + "item_id": { + "type": "integer" }, - "custom_answers": { - "readOnly": true, - "nullable": true + "product_name": { + "type": "string" }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "remaining_quantity": { + "type": "integer" }, - "payment": { - "allOf": [ - { - "$ref": "#/components/schemas/StaffPaymentSummary" - } - ], - "nullable": true, - "readOnly": true + "tracking_mode": { + "type": "string" + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DirectLoanReturnAsset" + } } }, "required": [ - "created_at", - "custom_answers", - "email", - "ends_at", - "id", - "name", - "note", - "payment", - "phone", - "public_token", - "space_id", - "starts_at", - "status" + "assets", + "item_id", + "product_name", + "remaining_quantity", + "tracking_mode" ] }, - "BookingEligibleMember": { + "DirectLoanUserAttribution": { "type": "object", "properties": { - "member_id": { - "type": "integer", - "readOnly": true + "username": { + "type": "string" + }, + "role": { + "type": "string" + } + }, + "required": [ + "role", + "username" + ] + }, + "Directory": { + "type": "object", + "properties": { + "members": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DirectoryEntry" + } + }, + "hidden_count": { + "type": "integer" + } + }, + "required": [ + "hidden_count", + "members" + ] + }, + "DirectoryEntry": { + "type": "object", + "description": "The listing row. Display name and avatar only -- never email, never phone.\n\nOtherwise \"see who else is here\" is an address harvest performed by anyone a space\nadmits, which is a different thing from what the member agreed to publish.", + "properties": { + "membership_id": { + "type": "integer" }, "display_name": { + "type": "string" + }, + "headline": { + "type": "string" + }, + "avatar_url": { "type": "string", - "readOnly": true + "nullable": true } }, "required": [ + "avatar_url", "display_name", - "member_id" + "headline", + "membership_id" ] }, - "BookingEligibleMemberList": { + "Discovery": { "type": "object", "properties": { "count": { @@ -70894,16 +92407,18 @@ }, "next": { "type": "string", + "format": "uri", "nullable": true }, "previous": { "type": "string", + "format": "uri", "nullable": true }, "results": { "type": "array", "items": { - "$ref": "#/components/schemas/BookingEligibleMember" + "$ref": "#/components/schemas/DiscoveryCard" } } }, @@ -70914,50 +92429,100 @@ "results" ] }, - "BookingListResponse": { + "DiscoveryCard": { "type": "object", "properties": { - "count": { - "type": "integer" + "title": { + "type": "string" }, - "next": { + "description": { + "type": "string" + }, + "url": { "type": "string", - "nullable": true + "format": "uri" }, - "previous": { + "image_url": { "type": "string", + "format": "uri", "nullable": true }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BookingAdmin" - } + "date": { + "type": "string" + }, + "location": { + "type": "string" + }, + "availability": { + "type": "string" + }, + "audience_label": { + "type": "string" + }, + "revision": { + "type": "string" + }, + "registration_count": { + "type": "integer", + "nullable": true + }, + "makerspace_name": { + "type": "string" } }, "required": [ - "count", - "results" + "audience_label", + "availability", + "date", + "description", + "image_url", + "location", + "makerspace_name", + "registration_count", + "revision", + "title", + "url" ] }, - "BookingMachineType": { + "DispositionEnum": { + "enum": [ + "needs_fix", + "remove" + ], + "type": "string", + "description": "* `needs_fix` - needs_fix\n* `remove` - remove" + }, + "DocTypeEnum": { + "enum": [ + "manual", + "sop" + ], + "type": "string", + "description": "* `manual` - Manual\n* `sop` - SOP" + }, + "DocumentFinalize": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true + "object_key": { + "type": "string", + "maxLength": 255 }, - "name": { + "doc_type": { "type": "string", - "readOnly": true + "maxLength": 16 + }, + "original_filename": { + "type": "string", + "maxLength": 255 } }, "required": [ - "id", - "name" + "doc_type", + "object_key", + "original_filename" ] }, - "BookingMachineTypePage": { + "DocumentPage": { "type": "object", "properties": { "count": { @@ -70976,7 +92541,7 @@ "results": { "type": "array", "items": { - "$ref": "#/components/schemas/BookingMachineType" + "$ref": "#/components/schemas/PaymentDocument" } } }, @@ -70987,513 +92552,766 @@ "results" ] }, - "BookingReschedule": { + "DocumentPresign": { "type": "object", "properties": { - "starts_at": { + "filename": { "type": "string", - "format": "date-time" + "maxLength": 255 }, - "ends_at": { + "content_type": { "type": "string", - "format": "date-time" + "maxLength": 100 } }, "required": [ - "ends_at", - "starts_at" + "content_type", + "filename" ] }, - "BookingUtilizationReport": { + "DomainVerificationRecord": { "type": "object", "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } + "host": { + "type": "string" }, - "typed_rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BookingUtilizationRow" - } + "type": { + "type": "string" + }, + "value": { + "type": "string" } }, "required": [ - "rows", - "typed_rows" + "host", + "type", + "value" ] }, - "BookingUtilizationRow": { + "DomainVerificationResponse": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" - }, - "space_id": { - "type": "integer" - }, - "space_name": { - "type": "string" + "status": { + "$ref": "#/components/schemas/StatusA39Enum" }, - "kind": { + "token": { "type": "string" }, - "is_active": { - "type": "boolean" - }, - "booked": { - "type": "integer" - }, - "completed": { - "type": "integer" + "expected_record": { + "allOf": [ + { + "$ref": "#/components/schemas/DomainVerificationRecord" + } + ], + "nullable": true }, - "no_show": { - "type": "integer" + "verified_at": { + "type": "string", + "format": "date-time", + "nullable": true }, - "cancelled": { - "type": "integer" + "detail": { + "type": "string" + } + }, + "required": [ + "detail", + "expected_record", + "status", + "token", + "verified_at" + ] + }, + "DownloadGrant": { + "type": "object", + "properties": { + "url": { + "type": "string" }, - "upcoming": { + "expires_in": { "type": "integer" - }, - "reserved_hours": { + } + }, + "required": [ + "expires_in", + "url" + ] + }, + "EducationEntry": { + "type": "object", + "properties": { + "institution": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + "maxLength": 200 }, - "completed_hours": { + "qualification": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + "default": "", + "maxLength": 200 }, - "window_hours": { + "year": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", - "nullable": true - }, - "reservation_utilization_percent": { - "type": "number", - "format": "double", - "nullable": true - }, - "no_show_rate_percent": { - "type": "number", - "format": "double", - "nullable": true + "default": "", + "maxLength": 20 } }, "required": [ - "booked", - "cancelled", - "completed", - "completed_hours", - "is_active", - "kind", - "no_show", - "no_show_rate_percent", - "reservation_utilization_percent", - "reserved_hours", - "space_id", - "space_name", - "upcoming", - "window_hours" + "institution" ] }, - "Bounce": { + "EmailLog": { "type": "object", "properties": { - "event_id": { + "id": { + "type": "integer", + "readOnly": true + }, + "to_email": { "type": "string", - "maxLength": 200 + "readOnly": true }, - "address": { + "subject": { "type": "string", - "format": "email", - "maxLength": 254 + "readOnly": true }, - "reason": { - "$ref": "#/components/schemas/BounceReasonEnum" + "stream": { + "type": "string", + "readOnly": true + }, + "event": { + "type": "string", + "readOnly": true + }, + "audience": { + "type": "string", + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/EmailLogStatusEnum" + } + ], + "readOnly": true + }, + "error": { + "type": "string", + "readOnly": true + }, + "attempts": { + "type": "integer", + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "sent_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true } }, "required": [ - "address", - "event_id", - "reason" + "attempts", + "audience", + "created_at", + "error", + "event", + "id", + "sent_at", + "status", + "stream", + "subject", + "to_email" ] }, - "BounceReasonEnum": { + "EmailLogStatusEnum": { "enum": [ - "bounce", - "complaint" + "pending", + "sending", + "sent", + "failed", + "skipped" ], "type": "string", - "description": "* `bounce` - bounce\n* `complaint` - complaint" - }, - "BounceResult": { - "type": "object", - "properties": { - "accepted": { - "type": "boolean" - } - }, - "required": [ - "accepted" - ] + "description": "* `pending` - Pending\n* `sending` - Sending\n* `sent` - Sent\n* `failed` - Failed\n* `skipped` - Skipped" }, - "Box": { + "EmailTemplateDetail": { "type": "object", "properties": { - "id": { - "type": "integer", + "stream": { + "type": "string", "readOnly": true }, - "makerspace": { - "type": "integer", + "audience": { + "type": "string", "readOnly": true }, - "parent": { - "type": "integer", - "nullable": true - }, - "code": { + "key": { "type": "string", "readOnly": true }, "label": { "type": "string", - "maxLength": 200 - }, - "location": { - "type": "string", - "maxLength": 200 + "readOnly": true }, "description": { - "type": "string" + "type": "string", + "readOnly": true }, - "kit_components": { + "fields": { "type": "array", "items": { - "$ref": "#/components/schemas/KitComponent" + "type": "object", + "additionalProperties": {} }, - "maxItems": 50 + "readOnly": true + }, + "subject": { + "type": "string", + "readOnly": true + }, + "text_body": { + "type": "string", + "readOnly": true + }, + "html_body": { + "type": "string", + "readOnly": true }, "is_active": { - "type": "boolean" + "type": "boolean", + "readOnly": true }, - "qr_code_id": { - "type": "integer", - "nullable": true, + "is_overridden": { + "type": "boolean", "readOnly": true }, - "created_at": { + "default_subject": { "type": "string", - "format": "date-time", "readOnly": true }, - "updated_at": { + "default_text": { + "type": "string", + "readOnly": true + }, + "default_html": { "type": "string", - "format": "date-time", "readOnly": true } }, "required": [ - "code", - "created_at", - "id", + "audience", + "default_html", + "default_subject", + "default_text", + "description", + "fields", + "html_body", + "is_active", + "is_overridden", + "key", "label", - "makerspace", - "qr_code_id", - "updated_at" + "stream", + "subject", + "text_body" ] }, - "Broadcast": { + "EmailTemplateListItem": { "type": "object", "properties": { - "id": { - "type": "integer", + "stream": { + "type": "string", "readOnly": true }, - "title": { + "audience": { "type": "string", "readOnly": true }, - "body": { + "key": { "type": "string", "readOnly": true }, - "created_at": { + "label": { "type": "string", - "format": "date-time", + "readOnly": true + }, + "is_active": { + "type": "boolean", + "readOnly": true + }, + "is_overridden": { + "type": "boolean", + "readOnly": true + }, + "can_edit_space_default": { + "type": "boolean", + "readOnly": true + }, + "overridable_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineTypeOption" + }, "readOnly": true } }, "required": [ - "body", - "created_at", - "id", - "title" + "audience", + "can_edit_space_default", + "is_active", + "is_overridden", + "key", + "label", + "overridable_types", + "stream" ] }, - "BroadcastInput": { + "EmailTemplatePreviewRequest": { "type": "object", "properties": { - "title": { + "stream": { + "type": "string" + }, + "audience": { + "type": "string" + }, + "key": { + "type": "string" + }, + "machine_type_id": { + "type": "integer" + }, + "subject": { "type": "string", "maxLength": 200 }, - "body": { - "type": "string", - "maxLength": 12000 + "text_body": { + "type": "string" }, - "plan_id": { - "type": "integer", - "minimum": 1 + "html_body": { + "type": "string" + } + }, + "required": [ + "audience", + "html_body", + "key", + "stream", + "subject", + "text_body" + ] + }, + "EmailTemplatePreviewResponse": { + "type": "object", + "properties": { + "subject": { + "type": "string", + "readOnly": true }, - "status": { - "$ref": "#/components/schemas/BroadcastInputStatusEnum" + "text_body": { + "type": "string", + "readOnly": true }, - "certification_id": { - "type": "integer", - "minimum": 1 + "html_body": { + "type": "string", + "readOnly": true } }, "required": [ - "body", - "title" + "html_body", + "subject", + "text_body" ] }, - "BroadcastInputStatusEnum": { + "EmailVerificationConfirm": { + "type": "object", + "properties": { + "code": { + "type": "string", + "maxLength": 6 + } + }, + "required": [ + "code" + ] + }, + "EndReasonEnum": { "enum": [ - "active", - "lapsed" + "superseded", + "membership_revoked", + "claim_revoked", + "user_ended" ], "type": "string", - "description": "* `active` - active\n* `lapsed` - lapsed" + "description": "* `superseded` - Superseded\n* `membership_revoked` - Membership revoked\n* `claim_revoked` - Claim revoked\n* `user_ended` - User ended" }, - "BroadcastOptions": { + "EnrollmentGrant": { "type": "object", + "description": "Returned exactly once: the raw token is never stored and cannot be re-read.", "properties": { - "plans": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SegmentChoice" - } + "enrollment_id": { + "type": "string", + "format": "uuid" }, - "certifications": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SegmentChoice" - } + "device_id": { + "type": "string", + "format": "uuid" + }, + "purpose": { + "$ref": "#/components/schemas/EnrollmentGrantPurposeEnum" + }, + "token": { + "type": "string" + }, + "expires_at": { + "type": "string", + "format": "date-time" } }, "required": [ - "certifications", - "plans" + "device_id", + "enrollment_id", + "expires_at", + "purpose", + "token" ] }, - "BroadcastResult": { + "EnrollmentGrantPurposeEnum": { + "enum": [ + "enroll", + "rotate", + "reactivate" + ], + "type": "string", + "description": "* `enroll` - Enroll\n* `rotate` - Rotate\n* `reactivate` - Reactivate" + }, + "EnvironmentEnum": { + "enum": [ + "development", + "production" + ], + "type": "string", + "description": "* `development` - Development\n* `production` - Production" + }, + "Error": { "type": "object", "properties": { - "broadcast": { - "$ref": "#/components/schemas/Broadcast" - }, - "recipients": { - "type": "integer" + "detail": { + "type": "string" } - }, - "required": [ - "broadcast", - "recipients" - ] + } }, - "BulkImportJob": { + "EventAdmin": { "type": "object", "properties": { + "participating_organizations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParticipatingOrganization" + }, + "readOnly": true + }, + "audience_label": { + "type": "string", + "nullable": true, + "readOnly": true + }, + "organization_admission_blocked": { + "type": "boolean", + "readOnly": true + }, "id": { "type": "integer", "readOnly": true }, - "mode": { - "allOf": [ - { - "$ref": "#/components/schemas/Mode087Enum" - } - ], + "staff_permissions": { + "type": "array", + "items": { + "type": "string" + }, "readOnly": true }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/BulkImportJobStatusEnum" - } - ], + "can_manage_staff": { + "type": "boolean", "readOnly": true }, - "total_rows": { + "makerspace_id": { "type": "integer", "readOnly": true }, - "processed_rows": { - "type": "integer", + "ticketing_features": { + "type": "array", + "items": { + "type": "string" + }, "readOnly": true }, - "created_count": { - "type": "integer", + "broadcasts_enabled": { + "type": "boolean", "readOnly": true }, - "updated_count": { - "type": "integer", + "report_features": { + "type": "array", + "items": { + "type": "string" + }, "readOnly": true }, - "error_count": { - "type": "integer", + "badge_sensitive_fields_allowed": { + "type": "boolean", "readOnly": true }, - "warning_count": { + "series_summary": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "integer" + }, + "public_token": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "timezone": { + "type": "string" + } + }, + "readOnly": true + }, + "series_revision": { "type": "integer", + "readOnly": true, + "nullable": true + }, + "series_override_fields": { "readOnly": true }, - "result": { + "title": { + "type": "string", "readOnly": true }, - "error": { + "description": { "type": "string", "readOnly": true }, - "created_at": { + "starts_at": { "type": "string", "format": "date-time", "readOnly": true }, - "updated_at": { + "ends_at": { "type": "string", "format": "date-time", "readOnly": true }, - "completed_at": { + "timezone_name": { + "type": "string", + "readOnly": true + }, + "location": { + "type": "string", + "readOnly": true + }, + "location_kind": { + "allOf": [ + { + "$ref": "#/components/schemas/LocationKindEnum" + } + ], + "readOnly": true + }, + "custom_form": { + "readOnly": true, + "nullable": true + }, + "capacity": { + "type": "integer", + "readOnly": true + }, + "payment_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true + }, + "registration_requires_approval": { + "type": "boolean", + "readOnly": true + }, + "registration_audience": { + "allOf": [ + { + "$ref": "#/components/schemas/EventDefaultRegistrationAudienceEnum" + } + ], + "readOnly": true + }, + "category_id": { + "type": "integer", + "nullable": true, + "readOnly": true + }, + "tags": { + "readOnly": true + }, + "cancellation_cutoff_hours": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "registration_cutoff_at": { "type": "string", "format": "date-time", "readOnly": true, "nullable": true - } - }, - "required": [ - "completed_at", - "created_at", - "created_count", - "error", - "error_count", - "id", - "mode", - "processed_rows", - "result", - "status", - "total_rows", - "updated_at", - "updated_count", - "warning_count" - ] - }, - "BulkImportJobCreate": { - "type": "object", - "properties": { - "mode": { - "$ref": "#/components/schemas/Mode087Enum" }, - "file": { - "type": "string", - "format": "uri", + "registration_cutoff_lead_minutes": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "effective_registration_cutoff_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "readOnly": true + }, + "registration_open": { + "type": "boolean", + "readOnly": true + }, + "offline_checkin_enabled": { + "type": "boolean", + "readOnly": true + }, + "is_public": { + "type": "boolean", + "readOnly": true + }, + "show_registration_count": { + "type": "boolean", + "readOnly": true + }, + "image_url": { + "type": "string", + "format": "uri", + "nullable": true, + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusFbbEnum" + } + ], + "readOnly": true + }, + "created_by_id": { + "type": "integer", + "readOnly": true, "nullable": true }, - "rows": { + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "registration_counts": { + "allOf": [ + { + "$ref": "#/components/schemas/EventRegistrationCounts" + } + ], + "readOnly": true + }, + "organizers": { "type": "array", "items": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/EventOrganizerSummary" }, - "maxItems": 5000 - }, - "mapping": {} + "readOnly": true + } }, "required": [ - "mode" + "audience_label", + "badge_sensitive_fields_allowed", + "broadcasts_enabled", + "can_manage_staff", + "cancellation_cutoff_hours", + "capacity", + "category_id", + "created_at", + "created_by_id", + "custom_form", + "description", + "effective_registration_cutoff_at", + "ends_at", + "id", + "image_url", + "is_public", + "location", + "location_kind", + "makerspace_id", + "offline_checkin_enabled", + "organization_admission_blocked", + "organizers", + "participating_organizations", + "payment_amount", + "registration_audience", + "registration_counts", + "registration_cutoff_at", + "registration_cutoff_lead_minutes", + "registration_open", + "registration_requires_approval", + "report_features", + "series_override_fields", + "series_revision", + "series_summary", + "show_registration_count", + "staff_permissions", + "starts_at", + "status", + "tags", + "ticketing_features", + "timezone_name", + "title", + "updated_at" ] }, - "BulkImportJobStatusEnum": { - "enum": [ - "pending", - "running", - "completed", - "failed" - ], - "type": "string", - "description": "* `pending` - Pending\n* `running` - Running\n* `completed` - Completed\n* `failed` - Failed" - }, - "BulkImportPreview": { + "EventAttendanceMark": { "type": "object", "properties": { - "file": { - "type": "string", - "format": "uri", - "nullable": true - }, - "rows": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": {} - }, - "maxItems": 5000 - }, - "mapping": {} + "source": { + "allOf": [ + { + "$ref": "#/components/schemas/EventAttendanceMarkSourceEnum" + } + ], + "default": "online" + } } }, - "CadenceEnum": { + "EventAttendanceMarkSourceEnum": { "enum": [ - "daily", - "weekly", - "monthly" + "online", + "qr" ], "type": "string", - "description": "* `daily` - Daily\n* `weekly` - Weekly\n* `monthly` - Monthly" - }, - "Capability": { - "type": "object", - "properties": { - "value": { - "type": "string" - }, - "label": { - "type": "string" - }, - "description": { - "type": "string" - }, - "group": { - "type": "string" - }, - "grantable": { - "type": "boolean" - }, - "lock_reason": { - "type": "string", - "nullable": true - } - }, - "required": [ - "description", - "grantable", - "group", - "label", - "lock_reason", - "value" - ] + "description": "* `online` - online\n* `qr` - qr" }, - "CashBookReport": { + "EventAttendanceReport": { "type": "object", "properties": { "rows": { @@ -71506,7 +93324,7 @@ "typed_rows": { "type": "array", "items": { - "$ref": "#/components/schemas/CashPeriod" + "$ref": "#/components/schemas/EventAttendanceRow" } } }, @@ -71515,162 +93333,227 @@ "typed_rows" ] }, - "CashPeriod": { + "EventAttendanceResult": { + "oneOf": [ + { + "$ref": "#/components/schemas/EventRegistrationAdmin" + }, + { + "$ref": "#/components/schemas/EventCheckInResult" + } + ] + }, + "EventAttendanceRow": { "type": "object", "properties": { "makerspace_id": { "type": "integer" }, - "currency": { + "event_id": { + "type": "integer" + }, + "series_id": { + "type": "integer", + "nullable": true + }, + "series_title": { "type": "string" }, - "collected_amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + "series_occurrence_key": { + "type": "string" }, - "refunded_amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + "title": { + "type": "string" }, - "period": { + "starts_at": { "type": "string", - "format": "date" + "format": "date-time" }, - "method": { + "status": { "type": "string" }, - "net_amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + "capacity": { + "type": "integer" + }, + "registrations": { + "type": "integer" + }, + "confirmed": { + "type": "integer" + }, + "pending_approval": { + "type": "integer" + }, + "registered": { + "type": "integer" + }, + "waitlisted": { + "type": "integer" + }, + "rejected": { + "type": "integer" + }, + "cancelled": { + "type": "integer" + }, + "attended": { + "type": "integer" + }, + "attendance_rate_percent": { + "type": "number", + "format": "double", + "nullable": true + }, + "organizers": { + "type": "string" } }, "required": [ - "collected_amount", - "currency", - "method", - "net_amount", - "period", - "refunded_amount" + "attendance_rate_percent", + "attended", + "cancelled", + "capacity", + "confirmed", + "event_id", + "organizers", + "pending_approval", + "registered", + "registrations", + "rejected", + "series_id", + "series_occurrence_key", + "series_title", + "starts_at", + "status", + "title", + "waitlisted" ] }, - "Category": { + "EventBroadcast": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "name": { - "type": "string", + "event": { + "type": "integer", "readOnly": true }, - "slug": { + "subject": { "type": "string", - "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" - } - }, - "required": [ - "id", - "name", - "slug" - ] - }, - "CategoryAdmin": { - "type": "object", - "properties": { - "id": { - "type": "integer", "readOnly": true }, - "makerspace": { - "type": "integer", + "body": { + "type": "string", "readOnly": true }, - "name": { - "type": "string", - "maxLength": 100 + "statuses": { + "readOnly": true }, - "slug": { - "oneOf": [ - { - "type": "string", - "pattern": "^[-a-zA-Z0-9_]+$" - }, + "attendance": { + "allOf": [ { - "type": "string", - "maxLength": 0 + "$ref": "#/components/schemas/EventBroadcastAttendanceEnum" } - ] - }, - "display_order": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "loan_days": { - "type": "integer", - "maximum": 365, - "minimum": 1, - "nullable": true - }, - "max_renewals": { - "type": "integer", - "maximum": 100, - "minimum": 0, - "nullable": true - }, - "icon": { - "type": "string", - "maxLength": 50 + ], + "readOnly": true }, - "product_count": { - "type": "integer", - "readOnly": true, - "default": 0 + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusAdeEnum" + } + ], + "readOnly": true }, "created_at": { "type": "string", "format": "date-time", "readOnly": true }, - "updated_at": { + "queued_at": { "type": "string", "format": "date-time", - "readOnly": true + "readOnly": true, + "nullable": true + }, + "completed_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true } }, "required": [ + "attendance", + "body", + "completed_at", "created_at", + "event", "id", - "makerspace", - "name", - "product_count", - "updated_at" + "queued_at", + "status", + "statuses", + "subject" ] }, - "CategoryInput": { + "EventBroadcastAttendanceEnum": { + "enum": [ + "any", + "attended", + "not_attended" + ], + "type": "string", + "description": "* `any` - Any\n* `attended` - Attended\n* `not_attended` - Not attended" + }, + "EventBroadcastInput": { "type": "object", "properties": { - "name": { + "subject": { "type": "string", - "maxLength": 100 + "maxLength": 200 }, - "slug": { + "body": { "type": "string", - "maxLength": 100, - "pattern": "^[-a-zA-Z0-9_]+$" + "maxLength": 10000 + }, + "statuses": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StatusesDf6Enum" + }, + "default": [ + "registered", + "attended" + ], + "maxItems": 6, + "minItems": 1 + }, + "attendance": { + "allOf": [ + { + "$ref": "#/components/schemas/EventBroadcastInputAttendanceEnum" + } + ], + "default": "any" } }, "required": [ - "name", - "slug" + "body", + "subject" ] }, - "CategoryList": { + "EventBroadcastInputAttendanceEnum": { + "enum": [ + "any", + "attended", + "not_attended" + ], + "type": "string", + "description": "* `any` - any\n* `attended` - attended\n* `not_attended` - not_attended" + }, + "EventBroadcastList": { "type": "object", "properties": { "count": { @@ -71689,7 +93572,7 @@ "results": { "type": "array", "items": { - "$ref": "#/components/schemas/Category" + "$ref": "#/components/schemas/EventBroadcast" } } }, @@ -71700,1710 +93583,1915 @@ "results" ] }, - "CertificateDownload": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "expires_at": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "expires_at", - "url" - ] - }, - "CertificateRevoke": { + "EventCheckInResolveRequest": { "type": "object", "properties": { - "reason": { - "$ref": "#/components/schemas/CertificateRevokeReasonEnum" + "checkin_token": { + "type": "string" } }, "required": [ - "reason" + "checkin_token" ] }, - "CertificateRevokeReasonEnum": { - "enum": [ - "staff_revoked" - ], - "type": "string", - "description": "* `staff_revoked` - staff_revoked" - }, - "CertificateSummary": { + "EventCheckInResolveResponse": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/CertificateSummaryStatusEnum" - } - ], - "readOnly": true + "registration_id": { + "type": "integer" }, - "revision": { - "type": "integer", - "readOnly": true + "name": { + "type": "string" }, - "issued_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "status": { + "type": "string" }, - "rendered_at": { + "payment_status": { "type": "string", - "format": "date-time", - "readOnly": true, "nullable": true }, - "revoked_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "host_waiver_state": { + "$ref": "#/components/schemas/HostWaiverStateEnum" + }, + "event_status": { + "type": "string" + }, + "confirmable": { + "type": "boolean" } }, "required": [ - "id", - "issued_at", - "rendered_at", - "revision", - "revoked_at", + "confirmable", + "event_status", + "host_waiver_state", + "name", + "payment_status", + "registration_id", "status" ] }, - "CertificateSummaryStatusEnum": { - "enum": [ - "pending", - "rendering", - "active", - "failed", - "revoked" - ], - "type": "string", - "description": "* `pending` - Pending\n* `rendering` - Rendering\n* `active` - Active\n* `failed` - Failed\n* `revoked` - Revoked" - }, - "CertificationGrant": { + "EventCheckInResult": { "type": "object", + "description": "Attendance operators need identity/state, never roster contacts or answers.", "properties": { "id": { "type": "integer", "readOnly": true }, - "certification_type": { - "type": "integer", - "readOnly": true - }, - "membership": { - "type": "integer", - "readOnly": true - }, - "member_name": { - "type": "string", - "readOnly": true - }, - "granted_by": { + "event_id": { "type": "integer", - "readOnly": true, - "nullable": true - }, - "granted_at": { - "type": "string", - "format": "date-time", "readOnly": true }, - "expires_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "revoked_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "revoked_by": { - "type": "integer", - "readOnly": true, - "nullable": true - }, - "notes": { + "name": { "type": "string", "readOnly": true }, - "is_live": { + "status": { "type": "string", "readOnly": true } }, "required": [ - "certification_type", - "expires_at", - "granted_at", - "granted_by", + "event_id", "id", - "is_live", - "member_name", - "membership", - "notes", - "revoked_at", - "revoked_by" + "name", + "status" ] }, - "CertificationGrantCreate": { + "EventCheckinTimesReport": { "type": "object", "properties": { - "membership_id": { - "type": "integer" - }, - "expires_at": { - "type": "string", - "format": "date-time", - "nullable": true + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "notes": { - "type": "string", - "default": "" + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventCheckinTimesRow" + } } }, "required": [ - "membership_id" + "rows", + "typed_rows" ] }, - "CertificationGrantPage": { + "EventCheckinTimesRow": { "type": "object", "properties": { - "count": { + "makerspace_id": { "type": "integer" }, - "next": { + "period": { "type": "string", - "nullable": true + "format": "date-time" }, - "previous": { - "type": "string", - "nullable": true + "event_id": { + "type": "integer" }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CertificationGrant" - } + "title": { + "type": "string" + }, + "timing_bucket": { + "type": "string" + }, + "checkin_count": { + "type": "integer" } }, "required": [ - "count", - "next", - "previous", - "results" + "checkin_count", + "event_id", + "period", + "timing_bucket", + "title" ] }, - "CertificationGrantRevoke": { - "type": "object", - "properties": { - "notes": { - "type": "string", - "default": "" - } - } - }, - "CertificationMember": { + "EventCollaborationInbox": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "name": { + "event_id": { + "type": "integer", + "readOnly": true + }, + "event_title": { + "type": "string", + "readOnly": true + }, + "starts_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "ends_at": { "type": "string", + "format": "date-time", + "readOnly": true + }, + "host_name": { + "type": "string", + "readOnly": true + }, + "host_slug": { + "type": "string", + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusB9dEnum" + } + ], + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", "readOnly": true + }, + "responded_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true } }, "required": [ + "created_at", + "ends_at", + "event_id", + "event_title", + "host_name", + "host_slug", "id", - "name" + "responded_at", + "starts_at", + "status" ] }, - "CertificationMemberPage": { + "EventCollaborationRespond": { "type": "object", "properties": { - "count": { - "type": "integer" - }, - "next": { - "type": "string", - "nullable": true - }, - "previous": { - "type": "string", - "nullable": true - }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CertificationMember" - } + "accept": { + "type": "boolean" } }, "required": [ - "count", - "next", - "previous", - "results" + "accept" ] }, - "CertificationType": { + "EventCollaborator": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "machine_type": { - "type": "integer" + "event_id": { + "type": "integer", + "readOnly": true }, - "name": { + "makerspace_id": { + "type": "integer", + "readOnly": true + }, + "makerspace_name": { "type": "string", - "maxLength": 200 + "readOnly": true }, - "description": { - "type": "string" + "makerspace_slug": { + "type": "string", + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "validity_days": { + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusB9dEnum" + } + ], + "readOnly": true + }, + "invited_by_id": { "type": "integer", - "maximum": 2147483647, - "minimum": 1, + "readOnly": true, "nullable": true }, - "is_required_for_service": { - "type": "boolean" - }, - "is_required_for_booking": { - "type": "boolean" - }, - "is_active": { - "type": "boolean", - "readOnly": true + "responded_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true }, "created_at": { "type": "string", "format": "date-time", "readOnly": true }, - "updated_at": { + "responded_at": { "type": "string", "format": "date-time", - "readOnly": true + "readOnly": true, + "nullable": true } }, "required": [ "created_at", + "event_id", "id", - "is_active", - "machine_type", - "name", - "updated_at" + "invited_by_id", + "makerspace_id", + "makerspace_name", + "makerspace_slug", + "responded_at", + "responded_by_id", + "status" ] }, - "CertificationTypePage": { + "EventCollaboratorReplace": { "type": "object", "properties": { - "count": { - "type": "integer" - }, - "next": { - "type": "string", - "nullable": true - }, - "previous": { - "type": "string", - "nullable": true - }, - "results": { + "slugs": { "type": "array", "items": { - "$ref": "#/components/schemas/CertificationType" + "type": "string", + "pattern": "^[-a-zA-Z0-9_]+$" } } }, "required": [ - "count", - "next", - "previous", - "results" + "slugs" ] }, - "ChainStatusEnum": { + "EventDefaultRegistrationAudienceEnum": { "enum": [ - "pending", - "locally_verified", - "invalid", - "unavailable" + "members", + "accounts", + "invite", + "participating_organizations" ], "type": "string", - "description": "* `pending` - Pending\n* `locally_verified` - Locally verified\n* `invalid` - Invalid\n* `unavailable` - Unavailable" + "description": "* `members` - Members\n* `accounts` - Authenticated accounts\n* `invite` - Invited accounts\n* `participating_organizations` - Participating organizations" }, - "ChallengeRequest": { + "EventDuplicate": { "type": "object", "properties": { - "token": { - "type": "string", - "maxLength": 256 - }, - "public_key": { - "type": "string", - "maxLength": 64 + "attach_series": { + "type": "boolean", + "default": false } - }, - "required": [ - "public_key", - "token" - ] + } }, - "ChallengeResponse": { + "EventEligibility": { "type": "object", "properties": { - "challenge": { - "type": "string" + "eligible": { + "type": "boolean", + "readOnly": true }, - "expires_at": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "challenge", - "expires_at" - ] - }, - "ChangePassword": { - "type": "object", - "properties": { - "current_password": { + "reason": { "type": "string", - "writeOnly": true + "readOnly": true, + "nullable": true }, - "new_password": { - "type": "string", - "writeOnly": true + "consent_repair_required": { + "type": "boolean", + "readOnly": true + }, + "host_waiver": { + "allOf": [ + { + "$ref": "#/components/schemas/HostWaiverProjection" + } + ], + "readOnly": true, + "nullable": true } }, "required": [ - "current_password", - "new_password" + "consent_repair_required", + "eligible", + "host_waiver", + "reason" ] }, - "ChangePasswordResponse": { + "EventEligibleMember": { "type": "object", + "description": "A picker row. Name and id only — a roster is not a contact export.", "properties": { - "detail": { + "member_id": { + "type": "integer", + "minimum": 1 + }, + "display_name": { "type": "string" } }, "required": [ - "detail" + "display_name", + "member_id" ] }, - "Channel59dEnum": { - "enum": [ - "telegram", - "slack", - "mattermost", - "discord", - "webhook" - ], - "type": "string", - "description": "* `telegram` - Telegram\n* `slack` - Slack\n* `mattermost` - Mattermost\n* `discord` - Discord\n* `webhook` - Signed webhook" - }, - "Channel673Enum": { - "enum": [ - "email", - "in_app", - "native_push" - ], - "type": "string", - "description": "* `email` - email\n* `in_app` - in_app\n* `native_push` - native_push" - }, - "ChannelB30Enum": { - "enum": [ - "in_app", - "email", - "telegram", - "slack", - "mattermost", - "discord", - "webhook", - "native_push" - ], - "type": "string", - "description": "* `in_app` - Inbox\n* `email` - Email\n* `telegram` - Telegram\n* `slack` - Slack\n* `mattermost` - Mattermost\n* `discord` - Discord\n* `webhook` - Signed webhook\n* `native_push` - Native push" - }, - "ChannelsEnum": { - "enum": [ - "email", - "telegram", - "slack", - "mattermost", - "discord", - "native_push", - "webhook" - ], - "type": "string", - "description": "* `email` - email\n* `telegram` - telegram\n* `slack` - slack\n* `mattermost` - mattermost\n* `discord` - discord\n* `native_push` - native_push\n* `webhook` - webhook" - }, - "CheckoutUrl": { + "EventFeedbackReport": { "type": "object", "properties": { - "checkout_url": { - "type": "string", - "format": "uri" + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventFeedbackReportRow" + } } }, "required": [ - "checkout_url" + "rows", + "typed_rows" ] }, - "ClaimRedemption": { + "EventFeedbackReportRow": { "type": "object", "properties": { - "code": { - "type": "string", - "writeOnly": true, - "maxLength": 64 + "response_id": { + "type": "integer" }, - "makerspace_slug": { + "created_at": { "type": "string", - "writeOnly": true, - "maxLength": 80, - "pattern": "^[-a-zA-Z0-9_]+$" + "format": "date-time" + }, + "registration_id": { + "type": "integer", + "nullable": true + }, + "answers": { + "type": "string" } }, "required": [ - "code", - "makerspace_slug" + "answers", + "created_at", + "registration_id", + "response_id" ] }, - "ClaimRedemptionResponse": { + "EventFeedbackScoresReport": { "type": "object", "properties": { - "user": { - "$ref": "#/components/schemas/AuthUserPayload" + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "access": { - "type": "string" + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventFeedbackScoresRow" + } } }, "required": [ - "access", - "user" + "rows", + "typed_rows" ] }, - "ClaimableInvitation": { + "EventFeedbackScoresRow": { "type": "object", "properties": { - "id": { + "makerspace_id": { "type": "integer" }, - "makerspace": { - "type": "object", - "additionalProperties": {} - }, - "inviter": { + "period": { "type": "string", - "nullable": true + "format": "date-time" }, - "auto_activates": { - "type": "boolean" + "event_id": { + "type": "integer" }, - "is_staff": { - "type": "boolean" + "title": { + "type": "string" }, - "role": { + "question_id": { + "type": "string" + }, + "score_count": { + "type": "integer" + }, + "score_total": { "type": "string", - "nullable": true + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$" + }, + "average_score": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$" } }, "required": [ - "auto_activates", - "id", - "inviter", - "is_staff", - "makerspace", - "role" + "average_score", + "event_id", + "period", + "question_id", + "score_count", + "score_total", + "title" ] }, - "ClientPlatformEnum": { - "enum": [ - "web", - "ios", - "android" - ], - "type": "string", - "description": "* `web` - Web\n* `ios` - iOS\n* `android` - Android" - }, - "ClientTypeEnum": { - "enum": [ - "browser", - "server" - ], - "type": "string", - "description": "* `browser` - Browser\n* `server` - Server" + "EventFunnelReport": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventFunnelRow" + } + } + }, + "required": [ + "rows", + "typed_rows" + ] }, - "ClosureApproval": { + "EventFunnelRow": { "type": "object", "properties": { - "id": { + "makerspace_id": { + "type": "integer" + }, + "period": { "type": "string", - "format": "uuid", - "readOnly": true + "format": "date-time" + }, + "event_id": { + "type": "integer" }, - "closure_digest": { - "type": "string", - "maxLength": 64 + "title": { + "type": "string" }, - "identity_count": { - "type": "string", - "readOnly": true + "registrations": { + "type": "integer" }, - "approved_count": { - "type": "string", - "readOnly": true + "confirmed": { + "type": "integer" }, - "approved_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "attended": { + "type": "integer" }, - "revoked_at": { - "type": "string", - "format": "date-time", + "confirmation_rate_percent": { + "type": "number", + "format": "double", + "nullable": true + }, + "attendance_rate_percent": { + "type": "number", + "format": "double", "nullable": true } }, "required": [ - "approved_at", - "approved_count", - "closure_digest", - "id", - "identity_count" + "attendance_rate_percent", + "attended", + "confirmation_rate_percent", + "confirmed", + "event_id", + "period", + "registrations", + "title" ] }, - "ClosureApprovalCreate": { + "EventListResponse": { "type": "object", "properties": { - "digest": { + "count": { + "type": "integer" + }, + "next": { "type": "string", - "maxLength": 64, - "minLength": 64 + "nullable": true }, - "decisions": { + "previous": { + "type": "string", + "nullable": true + }, + "results": { "type": "array", "items": { - "$ref": "#/components/schemas/IdentityDisclosureDecision" + "$ref": "#/components/schemas/EventAdmin" } } }, "required": [ - "decisions", - "digest" + "count", + "results" ] }, - "ClosureIdentity": { + "EventListWithDefaults": { "type": "object", "properties": { - "id": { + "count": { "type": "integer" }, - "username": { - "type": "string" - }, - "email": { - "oneOf": [ - { - "type": "string", - "format": "email" - }, - { - "type": "string", - "maxLength": 0 - } - ] - }, - "first_name": { - "type": "string" - }, - "last_name": { - "type": "string" + "next": { + "type": "string", + "nullable": true }, - "display_name": { - "type": "string" + "previous": { + "type": "string", + "nullable": true }, - "phone": { - "type": "string" + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventAdmin" + } }, - "date_joined": { - "type": "string", - "format": "date-time" + "default_registration_audience": { + "$ref": "#/components/schemas/EventDefaultRegistrationAudienceEnum" } }, "required": [ - "date_joined", - "display_name", - "email", - "first_name", - "id", - "last_name", - "phone", - "username" + "count", + "default_registration_audience", + "results" ] }, - "CollaborativeEvent": { + "EventOrganizerList": { "type": "object", "properties": { - "registration_audience": { - "allOf": [ - { - "$ref": "#/components/schemas/EventDefaultRegistrationAudienceEnum" - } - ], - "readOnly": true - }, - "category": { - "type": "object", - "nullable": true, - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "slug": { - "type": "string" - } - }, - "readOnly": true - }, - "tags": { + "organizers": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/EventOrganizerSummary" }, "readOnly": true - }, - "ticket_types": { + } + }, + "required": [ + "organizers" + ] + }, + "EventOrganizerReplace": { + "type": "object", + "properties": { + "organization_ids": { "type": "array", "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "amount": { - "type": "string" - }, - "audience": { - "type": "string" - }, - "spots_left": { - "type": "integer", - "nullable": true - } - } + "type": "integer", + "minimum": 1 }, + "maxItems": 50 + } + }, + "required": [ + "organization_ids" + ] + }, + "EventOrganizerSummary": { + "type": "object", + "properties": { + "id": { + "type": "integer", "readOnly": true }, - "promo_codes_enabled": { - "type": "boolean", - "readOnly": true - }, - "ticket_selection_required": { - "type": "boolean", - "readOnly": true - }, - "payment_amount": { + "slug": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "readOnly": true + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "currency": { + "name": { "type": "string", "readOnly": true + } + }, + "required": [ + "id", + "name", + "slug" + ] + }, + "EventRegistrationAdmin": { + "type": "object", + "properties": { + "no_longer_eligible": { + "type": "boolean", + "readOnly": true, + "default": false }, "id": { "type": "integer", "readOnly": true }, - "title": { - "type": "string", + "event_id": { + "type": "integer", "readOnly": true }, - "description": { + "name": { "type": "string", "readOnly": true }, - "starts_at": { + "email": { "type": "string", - "format": "date-time", "readOnly": true }, - "ends_at": { + "phone": { "type": "string", - "format": "date-time", "readOnly": true }, - "location": { - "type": "string", - "readOnly": true + "custom_answers": { + "readOnly": true, + "nullable": true }, - "location_kind": { + "status": { "allOf": [ { - "$ref": "#/components/schemas/LocationKindEnum" + "$ref": "#/components/schemas/StatusDf6Enum" } ], "readOnly": true }, - "custom_form": { - "readOnly": true, - "nullable": true - }, - "capacity": { - "type": "integer", - "minimum": 0, + "created_at": { + "type": "string", + "format": "date-time", "readOnly": true }, - "availability": { + "payment": { "allOf": [ { - "$ref": "#/components/schemas/AvailabilityEnum" + "$ref": "#/components/schemas/StaffPaymentSummary" } ], - "readOnly": true - }, - "registration_requires_approval": { - "type": "boolean", - "readOnly": true - }, - "effective_registration_cutoff_at": { - "type": "string", - "format": "date-time", "nullable": true, "readOnly": true }, - "registration_open": { - "type": "boolean", - "readOnly": true + "ticket_type_id": { + "type": "integer", + "readOnly": true, + "nullable": true }, - "image_url": { + "quoted_amount": { "type": "string", - "format": "uri", - "nullable": true, - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true, + "nullable": true }, - "host_name": { + "discount_amount": { "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", "readOnly": true }, - "host_slug": { + "cancelled_at": { "type": "string", + "format": "date-time", "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" - }, - "host_waiver": { - "allOf": [ - { - "$ref": "#/components/schemas/HostWaiver" - } - ], - "nullable": true, - "readOnly": true + "nullable": true }, - "organizers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EventOrganizerSummary" - }, + "status_before_cancel": { + "type": "string", "readOnly": true }, - "series": { - "type": "object", + "restore_until": { + "type": "string", + "format": "date-time", "nullable": true, "readOnly": true } }, "required": [ - "availability", - "capacity", - "category", - "currency", - "custom_form", - "description", - "effective_registration_cutoff_at", - "ends_at", - "host_name", - "host_slug", - "host_waiver", + "cancelled_at", + "created_at", + "custom_answers", + "discount_amount", + "email", + "event_id", "id", - "image_url", - "location", - "location_kind", - "organizers", - "payment_amount", - "promo_codes_enabled", - "registration_audience", - "registration_open", - "registration_requires_approval", - "series", - "starts_at", - "tags", - "ticket_selection_required", - "ticket_types", - "title" + "name", + "no_longer_eligible", + "payment", + "phone", + "quoted_amount", + "restore_until", + "status", + "status_before_cancel", + "ticket_type_id" ] }, - "CollaborativeEventRegistrationInput": { + "EventRegistrationCounts": { "type": "object", "properties": { - "custom_answers": { - "writeOnly": true, - "nullable": true - }, - "phone": { - "type": "string", - "writeOnly": true, - "maxLength": 64 + "pending_approval": { + "type": "integer", + "readOnly": true }, - "email": { - "writeOnly": true, - "oneOf": [ - { - "type": "string", - "format": "email" - }, - { - "type": "string", - "maxLength": 0 - } - ] + "registered": { + "type": "integer", + "readOnly": true }, - "invite_code": { - "type": "string", - "writeOnly": true, - "maxLength": 128 + "waitlisted": { + "type": "integer", + "readOnly": true }, - "ticket_type_id": { + "rejected": { "type": "integer", - "minimum": 1, - "writeOnly": true + "readOnly": true }, - "promo_code": { - "type": "string", - "writeOnly": true, - "maxLength": 128 + "cancelled": { + "type": "integer", + "readOnly": true }, - "host_waiver_id": { + "attended": { "type": "integer", - "minimum": 1, + "readOnly": true + } + }, + "required": [ + "attended", + "cancelled", + "pending_approval", + "registered", + "rejected", + "waitlisted" + ] + }, + "EventRegistrationListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", "nullable": true }, - "host_waiver_version": { + "previous": { "type": "string", - "nullable": true, - "maxLength": 64 + "nullable": true }, - "host_waiver_accepted": { - "type": "boolean", - "default": false + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventRegistrationAdmin" + } } - } + }, + "required": [ + "count", + "results" + ] }, - "CommandAck": { + "EventRegistrationReport": { "type": "object", "properties": { - "id": { - "type": "string", - "format": "uuid" + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventRegistrationReportRow" + } + } + }, + "required": [ + "rows", + "typed_rows" + ] + }, + "EventRegistrationReportRow": { + "type": "object", + "properties": { + "registration_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "phone": { + "type": "string" }, "status": { "type": "string" }, - "acknowledged_at": { + "created_at": { "type": "string", - "format": "date-time", - "nullable": true + "format": "date-time" + }, + "answers": { + "type": "string" } }, "required": [ - "acknowledged_at", - "id", + "answers", + "created_at", + "email", + "name", + "phone", + "registration_id", "status" ] }, - "CommandLease": { + "EventRevenueReport": { "type": "object", "properties": { - "command_id": { - "type": "string", - "format": "uuid" - }, - "kind": { - "type": "string" + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "lease_token": { - "type": "string" + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventRevenueRow" + } + } + }, + "required": [ + "rows", + "typed_rows" + ] + }, + "EventRevenueRow": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer" }, - "lease_expires_at": { + "period": { "type": "string", "format": "date-time" }, - "printer_job_id": { - "type": "string", - "format": "uuid" + "event_id": { + "type": "integer" }, - "external_job_id": { + "title": { "type": "string" }, - "service_request_id": { + "currency": { + "type": "string" + }, + "charge_count": { "type": "integer" }, - "label_command_id": { + "charged_amount": { "type": "string", - "format": "uuid" + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$" }, - "qr_batch_id": { - "type": "integer" + "promo_discount_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$" }, - "item_count": { - "type": "integer" - } - }, - "required": [ - "command_id", - "kind", - "lease_expires_at", - "lease_token" - ] - }, - "CompleteRequest": { - "type": "object", - "properties": { - "token": { + "collected_amount": { "type": "string", - "maxLength": 256 + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$" }, - "public_key": { + "refunded_amount": { "type": "string", - "maxLength": 64 + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$" }, - "challenge": { + "outstanding_amount": { "type": "string", - "maxLength": 64 + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$" }, - "signature": { + "net_amount": { "type": "string", - "maxLength": 128 + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$" } }, "required": [ - "challenge", - "public_key", - "signature", - "token" + "charge_count", + "charged_amount", + "collected_amount", + "currency", + "event_id", + "net_amount", + "outstanding_amount", + "period", + "promo_discount_amount", + "refunded_amount", + "title" ] }, - "ConditionEnum": { - "enum": [ - "available", - "damaged", - "lost", - "unknown" - ], - "type": "string", - "description": "* `available` - Available\n* `damaged` - Damaged\n* `lost` - Lost\n* `unknown` - Unknown" - }, - "ConnectStatusEnum": { - "enum": [ - "unconnected", - "pending", - "active", - "restricted", - "disconnected" - ], - "type": "string", - "description": "* `unconnected` - Unconnected\n* `pending` - Pending\n* `active` - Active\n* `restricted` - Restricted\n* `disconnected` - Disconnected" - }, - "ConnectedDeviceIdentity": { + "EventSeriesDetail": { "type": "object", "properties": { - "device_id": { + "id": { + "type": "integer", + "readOnly": true + }, + "staff_permissions": { + "type": "array", + "items": { + "type": "string" + }, + "readOnly": true + }, + "can_manage_staff": { + "type": "boolean", + "readOnly": true + }, + "public_token": { "type": "string", - "format": "uuid" + "format": "uuid", + "readOnly": true }, "makerspace_id": { - "type": "integer" + "type": "integer", + "readOnly": true + }, + "title": { + "type": "string", + "readOnly": true }, "status": { - "type": "string" + "allOf": [ + { + "$ref": "#/components/schemas/StatusFbbEnum" + } + ], + "readOnly": true }, - "key_version": { - "type": "integer" + "recurrence_timezone": { + "type": "string", + "readOnly": true }, - "last_seen_at": { + "dtstart_local_date": { "type": "string", - "format": "date-time", - "nullable": true - } - }, - "required": [ - "device_id", - "key_version", - "last_seen_at", - "makerspace_id", - "status" - ] - }, - "ConsumableCandidate": { - "type": "object", - "properties": { - "id": { + "format": "date", + "readOnly": true + }, + "dtstart_local_time": { + "type": "string", + "format": "time", + "readOnly": true + }, + "recurrence_rule": { + "type": "string", + "readOnly": true + }, + "duration_minutes": { "type": "integer", "readOnly": true }, - "name": { + "revision": { + "type": "integer", + "readOnly": true + }, + "next_occurrence_at": { "type": "string", + "format": "date-time", "readOnly": true }, - "available": { + "future_occurrence_count": { "type": "integer", "readOnly": true - } - }, - "required": [ - "available", - "id", - "name" - ] - }, - "Consumption": { - "type": "object", - "properties": { - "machine_consumable_id": { + }, + "last_materialized_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "last_generation_error_code": { + "type": "string", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "participating_organizations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParticipatingOrganization" + }, + "readOnly": true + }, + "audience_label": { + "type": "string", + "nullable": true, + "readOnly": true + }, + "organization_admission_blocked": { + "type": "boolean", + "readOnly": true + }, + "organization_audience_enabled": { + "type": "boolean", + "readOnly": true + }, + "website_enabled": { + "type": "boolean", + "readOnly": true + }, + "description": { + "type": "string" + }, + "location": { + "type": "string", + "maxLength": 255 + }, + "location_kind": { + "$ref": "#/components/schemas/LocationKindEnum" + }, + "custom_form": { + "nullable": true + }, + "capacity": { "type": "integer", - "minimum": 1 + "maximum": 2147483647, + "minimum": 0 }, - "quantity": { + "payment_amount": { "type": "string", "format": "decimal", "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "registration_requires_approval": { + "type": "boolean" + }, + "registration_audience": { + "$ref": "#/components/schemas/EventDefaultRegistrationAudienceEnum" + }, + "cancellation_cutoff_hours": { + "type": "integer", + "maximum": 8760, + "minimum": 0, + "nullable": true + }, + "registration_cutoff_lead_minutes": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0, + "nullable": true + }, + "is_public": { + "type": "boolean" + }, + "created_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "image_url": { + "type": "string", + "format": "uri", + "readOnly": true } }, "required": [ - "machine_consumable_id", - "quantity" + "audience_label", + "can_manage_staff", + "created_at", + "created_by_id", + "dtstart_local_date", + "dtstart_local_time", + "duration_minutes", + "future_occurrence_count", + "id", + "image_url", + "last_generation_error_code", + "last_materialized_at", + "makerspace_id", + "next_occurrence_at", + "organization_admission_blocked", + "organization_audience_enabled", + "participating_organizations", + "public_token", + "recurrence_rule", + "recurrence_timezone", + "revision", + "staff_permissions", + "status", + "title", + "updated_at", + "website_enabled" ] }, - "ContainerAssetSummary": { + "EventSeriesListResponse": { "type": "object", "properties": { - "id": { + "count": { "type": "integer" }, - "asset_tag": { - "type": "string" + "next": { + "type": "string", + "nullable": true }, - "product": { - "type": "string" + "previous": { + "type": "string", + "nullable": true }, - "status": { - "type": "string" + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventSeriesSummary" + } } }, "required": [ - "asset_tag", - "id", - "product", - "status" + "count", + "next", + "previous", + "results" ] }, - "ContainerContents": { + "EventSeriesMutationResponse": { "type": "object", "properties": { - "container": { - "$ref": "#/components/schemas/Box" + "series": { + "allOf": [ + { + "$ref": "#/components/schemas/EventSeriesDetail" + } + ], + "readOnly": true }, - "products": { + "created_occurrence_ids": { "type": "array", "items": { - "$ref": "#/components/schemas/ContainerProductSummary" - } + "type": "integer" + }, + "readOnly": true }, - "assets": { + "removed_occurrence_ids": { "type": "array", "items": { - "$ref": "#/components/schemas/ContainerAssetSummary" - } + "type": "integer" + }, + "readOnly": true }, - "children": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Box" - } + "affected_count": { + "type": "integer", + "readOnly": true } }, "required": [ - "assets", - "children", - "container", - "products" + "affected_count", + "created_occurrence_ids", + "removed_occurrence_ids", + "series" ] }, - "ContainerHistory": { + "EventSeriesSummary": { "type": "object", "properties": { - "container": { - "type": "integer" + "id": { + "type": "integer", + "readOnly": true }, - "scans": { + "staff_permissions": { "type": "array", "items": { - "$ref": "#/components/schemas/ContainerScanHistoryItem" - } + "type": "string" + }, + "readOnly": true + }, + "can_manage_staff": { + "type": "boolean", + "readOnly": true + }, + "public_token": { + "type": "string", + "format": "uuid", + "readOnly": true + }, + "makerspace_id": { + "type": "integer", + "readOnly": true + }, + "title": { + "type": "string", + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusFbbEnum" + } + ], + "readOnly": true + }, + "recurrence_timezone": { + "type": "string", + "readOnly": true + }, + "dtstart_local_date": { + "type": "string", + "format": "date", + "readOnly": true + }, + "dtstart_local_time": { + "type": "string", + "format": "time", + "readOnly": true + }, + "recurrence_rule": { + "type": "string", + "readOnly": true + }, + "duration_minutes": { + "type": "integer", + "readOnly": true + }, + "revision": { + "type": "integer", + "readOnly": true + }, + "next_occurrence_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "future_occurrence_count": { + "type": "integer", + "readOnly": true + }, + "last_materialized_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "last_generation_error_code": { + "type": "string", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true } }, "required": [ - "container", - "scans" + "can_manage_staff", + "dtstart_local_date", + "dtstart_local_time", + "duration_minutes", + "future_occurrence_count", + "id", + "last_generation_error_code", + "last_materialized_at", + "makerspace_id", + "next_occurrence_at", + "public_token", + "recurrence_rule", + "recurrence_timezone", + "revision", + "staff_permissions", + "status", + "title", + "updated_at" ] }, - "ContainerMove": { + "EventSeriesWrite": { "type": "object", "properties": { - "kit_components": { + "registration_audience": { + "$ref": "#/components/schemas/EventSeriesWriteRegistrationAudienceEnum" + }, + "ticket_types": { "type": "array", "items": { - "$ref": "#/components/schemas/KitComponent" + "$ref": "#/components/schemas/TicketInput" }, - "maxItems": 50 + "writeOnly": true }, - "parent_id": { - "type": "integer", + "title": { + "type": "string", + "maxLength": 200 + }, + "description": { + "type": "string", + "default": "" + }, + "location": { + "type": "string", + "default": "", + "maxLength": 255 + }, + "location_kind": { + "allOf": [ + { + "$ref": "#/components/schemas/LocationKindEnum" + } + ], + "default": "other" + }, + "custom_form": { "nullable": true }, - "label": { - "type": "string" + "capacity": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "payment_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "default": "0.00" }, - "location": { - "type": "string" + "registration_requires_approval": { + "type": "boolean", + "default": false }, - "description": { - "type": "string" + "cancellation_cutoff_hours": { + "type": "integer", + "maximum": 8760, + "minimum": 0, + "nullable": true }, - "is_active": { - "type": "boolean" - } - } - }, - "ContainerProductSummary": { - "type": "object", - "properties": { - "id": { - "type": "integer" + "registration_cutoff_lead_minutes": { + "type": "integer", + "minimum": 0, + "nullable": true }, - "name": { - "type": "string" + "is_public": { + "type": "boolean", + "default": false }, - "available_quantity": { - "type": "integer" + "recurrence_timezone": { + "type": "string", + "maxLength": 64 }, - "tracking_mode": { - "type": "string" - } - }, - "required": [ - "available_quantity", - "id", - "name", - "tracking_mode" - ] - }, - "ContainerScanHistoryItem": { - "type": "object", - "properties": { - "id": { - "type": "string" + "dtstart_local_date": { + "type": "string", + "format": "date" }, - "source": { - "type": "string" + "dtstart_local_time": { + "type": "string", + "format": "time" }, - "context": { - "type": "string" + "recurrence_rule": { + "type": "string", + "maxLength": 500 }, - "actor": { + "duration_minutes": { "type": "integer", - "nullable": true + "minimum": 1 }, - "created_at": { + "effective_from": { "type": "string", - "format": "date-time" + "format": "date-time", + "writeOnly": true } }, "required": [ - "actor", - "context", - "created_at", - "id", - "source" + "dtstart_local_date", + "dtstart_local_time", + "duration_minutes", + "recurrence_rule", + "recurrence_timezone", + "title" ] }, - "ContextEnum": { + "EventSeriesWriteRegistrationAudienceEnum": { "enum": [ - "issue", - "return", - "inventory_check" + "members", + "accounts", + "invite", + "participating_organizations" ], "type": "string", - "description": "* `issue` - issue\n* `return` - return\n* `inventory_check` - inventory_check" + "description": "* `members` - members\n* `accounts` - accounts\n* `invite` - invite\n* `participating_organizations` - participating_organizations" }, - "Count": { + "EventSignupsReport": { "type": "object", "properties": { - "count": { - "type": "integer" + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventSignupsRow" + } } }, "required": [ - "count" + "rows", + "typed_rows" ] }, - "CreateBoxQr": { + "EventSignupsRow": { "type": "object", "properties": { "makerspace_id": { "type": "integer" }, - "label": { - "type": "string" + "period": { + "type": "string", + "format": "date-time" }, - "location": { - "type": "string" + "event_id": { + "type": "integer" }, - "description": { + "title": { "type": "string" }, - "parent_id": { - "type": "integer", - "nullable": true + "registrations": { + "type": "integer" + }, + "cumulative_registrations": { + "type": "integer" } }, "required": [ - "label", - "makerspace_id" + "cumulative_registrations", + "event_id", + "period", + "registrations", + "title" ] }, - "CreateToolQr": { + "EventStaffRegistration": { "type": "object", + "description": "Staff registering an identified account under the event's audience rules.\n\n`member_id` only. Contact details are copied off the account by the registration\nservice, so a staffer cannot record an attendee under a name and email that belong\nto nobody — which is what makes the attendee list usable as an accountability record\nrather than free text.", "properties": { - "makerspace_id": { - "type": "integer" + "member_id": { + "type": "integer", + "minimum": 1 }, - "product_id": { - "type": "integer" + "invite_code": { + "type": "string", + "writeOnly": true, + "maxLength": 128 }, - "asset_id": { - "type": "integer" + "ticket_type_id": { + "type": "integer", + "minimum": 1 + }, + "promo_code": { + "type": "string", + "writeOnly": true, + "maxLength": 128 + }, + "custom_answers": { + "nullable": true + }, + "phone": { + "type": "string", + "default": "", + "maxLength": 32 + }, + "email": { + "oneOf": [ + { + "type": "string", + "format": "email", + "default": "" + }, + { + "type": "string", + "maxLength": 0 + } + ] } }, "required": [ - "makerspace_id" + "member_id" ] }, - "CreationInviteDelivery": { + "EventWebsitePublishInput": { "type": "object", "properties": { - "accept_url": { - "type": "string" + "publish_website": { + "type": "boolean", + "default": false }, - "delivery_status": { - "type": "string" + "website_revision": { + "type": "integer", + "minimum": 1 } - }, - "required": [ - "accept_url", - "delivery_status" - ] + } }, - "CreationProfile": { + "EventWrite": { "type": "object", - "description": "Explicit fields override a pack; omitted fields keep its configuration.", "properties": { - "timezone": { + "registration_audience": { + "$ref": "#/components/schemas/EventDefaultRegistrationAudienceEnum" + }, + "category_id": { + "type": "integer", + "minimum": 1, + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 50 + }, + "maxItems": 20 + }, + "title": { "type": "string", - "maxLength": 100 + "maxLength": 200 }, - "country": { + "description": { "type": "string", - "maxLength": 2 + "default": "" }, - "opening_hours": { - "oneOf": [ - { - "type": "object", - "maxProperties": 0 - }, + "starts_at": { + "type": "string", + "format": "date-time" + }, + "ends_at": { + "type": "string", + "format": "date-time" + }, + "timezone_name": { + "type": "string", + "maxLength": 64 + }, + "location": { + "type": "string", + "default": "", + "maxLength": 255 + }, + "location_kind": { + "allOf": [ { - "type": "object", - "required": [ - "weekly", - "exceptions" - ], - "additionalProperties": false, - "properties": { - "weekly": { - "oneOf": [ - { - "type": "object", - "maxProperties": 0 - }, - { - "type": "object", - "additionalProperties": false, - "required": [ - "mon", - "tue", - "wed", - "thu", - "fri", - "sat", - "sun" - ], - "properties": { - "mon": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "tue": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "wed": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "thu": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "fri": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "sat": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "sun": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - } - } - } - ] - }, - "exceptions": { - "type": "object", - "maxProperties": 366, - "additionalProperties": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "description": "YYYY-MM-DD keys; each value replaces that date's weekly hours." - } - } + "$ref": "#/components/schemas/LocationKindEnum" } ], - "description": "Empty object means always open. Empty weekly hours mean always open except dated overrides. Empty interval lists mean closed. Sorted intervals use local wall time; split overnight hours across days." + "default": "other" }, - "default_currency": { + "custom_form": { + "nullable": true + }, + "capacity": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "payment_amount": { "type": "string", - "maxLength": 3 + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "default": "0.00" + }, + "is_public": { + "type": "boolean", + "default": false + }, + "show_registration_count": { + "type": "boolean", + "default": true + }, + "registration_requires_approval": { + "type": "boolean", + "default": false + }, + "cancellation_cutoff_hours": { + "type": "integer", + "maximum": 8760, + "minimum": 0, + "nullable": true, + "description": "Member cancellation closes this many hours before start; null means start." + }, + "registration_cutoff_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "registration_cutoff_lead_minutes": { + "type": "integer", + "minimum": 0, + "nullable": true }, - "timezone_choices": { + "inherit_fields": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/InheritFieldsEnum" }, - "readOnly": true + "writeOnly": true + } + }, + "required": [ + "ends_at", + "starts_at", + "title" + ] + }, + "EvidenceGetResponse": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" }, - "country_choices": { - "type": "array", - "items": { - "type": "string" - }, - "readOnly": true + "expires_in": { + "type": "integer" } }, "required": [ - "country_choices", - "timezone_choices" + "expires_in", + "url" ] }, - "CustodyState": { + "EvidenceRetention": { "type": "object", + "description": "Retention coordination only: ``null`` on the event means no state row exists,\nwhich is NOT a claim that the bytes are available.", "properties": { "state": { - "type": "string" - }, - "reason_code": { - "type": "string" - }, - "entered_at": { - "type": "string", - "format": "date-time", - "nullable": true + "$ref": "#/components/schemas/EvidenceRetentionStateEnum" }, - "cleared_at": { + "object_expired_at": { "type": "string", "format": "date-time", "nullable": true } }, "required": [ - "cleared_at", - "entered_at", - "reason_code", + "object_expired_at", "state" ] }, - "CutoverOutcome": { + "EvidenceRetentionPolicy": { "type": "object", "properties": { - "message": { - "type": "string" + "makerspace_id": { + "type": "integer" }, - "receipt": { - "$ref": "#/components/schemas/ReceiptEnvelope" + "platform_default_days": { + "type": "integer" + }, + "override_days": { + "type": "integer", + "nullable": true + }, + "effective_days": { + "type": "integer" + }, + "object_expiry_enabled": { + "type": "boolean" + }, + "abandoned_upload_cleanup_enabled": { + "type": "boolean" + }, + "abandoned_upload_platform_default_days": { + "type": "integer" + }, + "abandoned_upload_override_days": { + "type": "integer", + "nullable": true + }, + "abandoned_upload_effective_days": { + "type": "integer" + }, + "abandoned_upload_global_enabled": { + "type": "boolean" } }, "required": [ - "message" + "abandoned_upload_cleanup_enabled", + "abandoned_upload_effective_days", + "abandoned_upload_global_enabled", + "abandoned_upload_override_days", + "abandoned_upload_platform_default_days", + "effective_days", + "makerspace_id", + "object_expiry_enabled", + "override_days", + "platform_default_days" ] }, - "CutoverReceiptRequest": { + "EvidenceRetentionPreviewRequest": { "type": "object", "properties": { - "receipt": { - "$ref": "#/components/schemas/ReceiptEnvelope" + "limit": { + "type": "integer", + "maximum": 1000, + "minimum": 1, + "default": 100 + } + } + }, + "EvidenceRetentionPreviewResponse": { + "type": "object", + "properties": { + "as_of": { + "type": "string", + "format": "date-time" + }, + "policy_days": { + "type": "integer" + }, + "cutoff": { + "type": "string", + "format": "date-time" + }, + "object_candidates": { + "type": "integer" + }, + "candidate_bytes": { + "type": "integer" + }, + "has_more": { + "type": "boolean" + }, + "abandoned_upload_cutoff": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "abandoned_upload_candidates": { + "type": "integer" + }, + "abandoned_upload_candidate_bytes": { + "type": "integer" + }, + "abandoned_upload_has_more": { + "type": "boolean" + }, + "late_write_receipts": { + "type": "integer" + }, + "late_write_receipts_due": { + "type": "integer" } }, "required": [ - "receipt" + "abandoned_upload_candidate_bytes", + "abandoned_upload_candidates", + "abandoned_upload_cutoff", + "abandoned_upload_has_more", + "as_of", + "candidate_bytes", + "cutoff", + "has_more", + "late_write_receipts", + "late_write_receipts_due", + "object_candidates", + "policy_days" ] }, - "DamagedLostReport": { + "EvidenceRetentionStateEnum": { + "enum": [ + "expiring", + "expired" + ], + "type": "string", + "description": "* `expiring` - expiring\n* `expired` - expired" + }, + "EvidenceType740Enum": { + "enum": [ + "issue", + "return" + ], + "type": "string", + "description": "* `issue` - issue\n* `return` - return" + }, + "EvidenceUrlRequest": { "type": "object", "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } + "evidence_type": { + "$ref": "#/components/schemas/EvidenceUrlRequestEvidenceTypeEnum" }, - "typed_rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DamagedLostReportRow" - } + "content_type": { + "type": "string" + }, + "size_bytes": { + "type": "integer", + "minimum": 0, + "nullable": true } }, "required": [ - "rows", - "typed_rows" + "content_type", + "evidence_type" ] }, - "DamagedLostReportRow": { + "EvidenceUrlRequestEvidenceTypeEnum": { + "enum": [ + "issue", + "return" + ], + "type": "string", + "description": "* `issue` - Issue\n* `return` - Return" + }, + "EvidenceUrlResponse": { "type": "object", "properties": { - "makerspace_id": { + "evidence_id": { "type": "integer" }, - "product_name": { + "upload_url": { + "type": "string", + "format": "uri" + }, + "fields": { + "type": "object", + "additionalProperties": {} + }, + "object_key": { "type": "string" }, - "damaged_quantity": { - "type": "integer" + "method": { + "type": "string" }, - "lost_quantity": { - "type": "integer" + "headers": { + "type": "object", + "additionalProperties": {} } }, "required": [ - "damaged_quantity", - "lost_quantity", - "product_name" + "evidence_id", + "fields", + "object_key", + "upload_url" ] }, - "DamagedMissingReport": { + "FabLabHealthReport": { "type": "object", "properties": { "rows": { @@ -73416,7 +95504,7 @@ "typed_rows": { "type": "array", "items": { - "$ref": "#/components/schemas/DamagedMissingReportRow" + "$ref": "#/components/schemas/FabLabHealthRow" } } }, @@ -73425,311 +95513,328 @@ "typed_rows" ] }, - "DamagedMissingReportRow": { + "FabLabHealthRow": { "type": "object", "properties": { "makerspace_id": { - "type": "integer" + "type": "integer", + "description": "Tenant dimension included in both local and aggregate health reports." }, - "product": { - "type": "string" + "events_enabled": { + "type": "boolean" }, - "damaged_quantity": { - "type": "integer" + "events_available": { + "type": "boolean" }, - "missing_quantity": { - "type": "integer" - } - }, - "required": [ - "damaged_quantity", - "missing_quantity", - "product" - ] - }, - "Dashboard": { - "type": "object", - "properties": { - "upcoming_events": { + "events_in_period": { "type": "integer", - "default": 0 + "nullable": true }, - "pending_event_registrations": { + "events_registrations": { "type": "integer", - "default": 0 + "nullable": true }, - "pending_bookings": { + "events_attended": { "type": "integer", - "default": 0 + "nullable": true }, - "upcoming_bookings": { - "type": "integer", - "default": 0 + "events_completed_attendance_rate_percent": { + "type": "number", + "format": "double", + "nullable": true }, - "scope_mode": { - "$ref": "#/components/schemas/ScopeModeEnum" + "bookings_enabled": { + "type": "boolean" }, - "overdue_loans": { - "type": "integer", - "default": 0 + "bookings_available": { + "type": "boolean" }, - "pending_requests": { + "bookings_active_spaces": { "type": "integer", - "default": 0 + "nullable": true }, - "awaiting_issue": { + "bookings_non_cancelled": { "type": "integer", - "default": 0 + "nullable": true }, - "open_problem_reports": { - "type": "integer", - "default": 0 + "bookings_reserved_hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", + "nullable": true }, - "low_stock": { + "bookings_upcoming": { "type": "integer", - "default": 0 + "nullable": true }, - "pending_prints": { + "bookings_no_shows": { "type": "integer", - "default": 0 + "nullable": true }, - "active_prints": { - "type": "integer", - "default": 0 + "bookings_reservation_utilization_percent": { + "type": "number", + "format": "double", + "nullable": true }, - "prints_awaiting_collection": { - "type": "integer", - "default": 0 + "machines_enabled": { + "type": "boolean" }, - "failed_emails": { - "type": "integer", - "default": 0 + "machines_available": { + "type": "boolean" }, - "stocktakes_awaiting_approval": { + "machines_active": { "type": "integer", - "default": 0 + "nullable": true }, - "warranty_expiring": { - "type": "integer", - "default": 0 + "machines_usage_hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", + "nullable": true }, - "maintenance_overdue": { - "type": "integer", - "default": 0 + "maintenance_enabled": { + "type": "boolean" }, - "pending_payments": { + "maintenance_available": { + "type": "boolean" + }, + "maintenance_logs": { "type": "integer", - "default": 0 + "nullable": true }, - "outstanding_by_currency": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "maintenance_total_cost": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", + "nullable": true + }, + "maintenance_overdue_schedules": { + "type": "integer", + "nullable": true } }, "required": [ - "scope_mode" + "bookings_active_spaces", + "bookings_available", + "bookings_enabled", + "bookings_no_shows", + "bookings_non_cancelled", + "bookings_reservation_utilization_percent", + "bookings_reserved_hours", + "bookings_upcoming", + "events_attended", + "events_available", + "events_completed_attendance_rate_percent", + "events_enabled", + "events_in_period", + "events_registrations", + "machines_active", + "machines_available", + "machines_enabled", + "machines_usage_hours", + "maintenance_available", + "maintenance_enabled", + "maintenance_logs", + "maintenance_overdue_schedules", + "maintenance_total_cost", + "makerspace_id" ] }, - "DataExportCreate": { + "FactorRequiredEnum": { + "enum": [ + "totp", + "phone", + "email_code" + ], + "type": "string", + "description": "* `totp` - totp\n* `phone` - phone\n* `email_code` - email_code" + }, + "FailedDeliveries": { "type": "object", "properties": { - "fidelity": { - "allOf": [ - { - "$ref": "#/components/schemas/FidelityEnum" - } - ], - "default": "REDACTED" + "email": { + "type": "integer" + }, + "notifications": { + "type": "integer" } - } + }, + "required": [ + "email", + "notifications" + ] }, - "DataExportDownloadUrl": { + "FailureCodeEnum": { + "enum": [ + "deadline_exceeded", + "integrity_error", + "storage_error", + "quota_exceeded", + "internal_error" + ], + "type": "string", + "description": "* `` - None\n* `deadline_exceeded` - Deadline exceeded\n* `integrity_error` - Integrity error\n* `storage_error` - Storage error\n* `quota_exceeded` - Quota exceeded\n* `internal_error` - Internal error" + }, + "Feature4feEnum": { + "enum": [ + "hardware_requests", + "printing", + "events", + "bookings", + "maintenance", + "members", + "api_access", + "devices", + "payments", + "stock", + "certifications" + ], + "type": "string", + "description": "* `hardware_requests` - Hardware requests\n* `printing` - Printing\n* `events` - Events\n* `bookings` - Bookings\n* `maintenance` - Maintenance\n* `members` - Members\n* `api_access` - API access\n* `devices` - Devices\n* `payments` - Payments\n* `stock` - Stock and procurement\n* `certifications` - Certifications" + }, + "Feature9d8Enum": { + "enum": [ + "hardware_requests", + "printing", + "events", + "bookings", + "maintenance", + "members", + "api_access", + "devices", + "payments", + "stock", + "certifications" + ], + "type": "string", + "description": "* `hardware_requests` - hardware_requests\n* `printing` - printing\n* `events` - events\n* `bookings` - bookings\n* `maintenance` - maintenance\n* `members` - members\n* `api_access` - api_access\n* `devices` - devices\n* `payments` - payments\n* `stock` - stock\n* `certifications` - certifications" + }, + "FeedIssued": { "type": "object", "properties": { - "url": { + "feed_url": { "type": "string", "format": "uri" }, - "expires_at": { - "type": "string", - "format": "date-time" + "token_hint": { + "type": "string" } }, "required": [ - "expires_at", - "url" + "feed_url", + "token_hint" ] }, - "DataExportJob": { + "FeedState": { "type": "object", "properties": { - "id": { - "type": "string", - "format": "uuid", - "readOnly": true + "enabled": { + "type": "boolean" }, - "fidelity": { + "token_hint": { "type": "string", + "nullable": true + } + }, + "required": [ + "enabled", + "token_hint" + ] + }, + "FeedbackForm": { + "type": "object", + "properties": { + "event": { + "type": "object", + "additionalProperties": {}, "readOnly": true }, - "status": { + "survey": { "allOf": [ { - "$ref": "#/components/schemas/StatusE1dEnum" + "$ref": "#/components/schemas/FeedbackSurvey" } ], "readOnly": true }, - "manifest": { - "readOnly": true - }, - "failure_code": { - "readOnly": true, - "oneOf": [ - { - "$ref": "#/components/schemas/FailureCodeEnum" - }, + "mode": { + "allOf": [ { - "$ref": "#/components/schemas/BlankEnum" + "$ref": "#/components/schemas/FeedbackFormModeEnum" } - ] - }, - "failure_detail": { - "type": "string", + ], "readOnly": true }, - "deadline_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "snapshot_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "started_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "requires_auth": { + "type": "boolean", + "readOnly": true }, - "completed_at": { - "type": "string", - "format": "date-time", + "certificate": { + "allOf": [ + { + "$ref": "#/components/schemas/CertificateSummary" + } + ], "readOnly": true, "nullable": true - }, - "expires_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true } }, "required": [ - "completed_at", - "created_at", - "deadline_at", - "expires_at", - "failure_code", - "failure_detail", - "fidelity", - "id", - "manifest", - "snapshot_at", - "started_at", - "status" + "certificate", + "event", + "mode", + "requires_auth", + "survey" ] }, - "Delivery": { + "FeedbackFormModeEnum": { + "enum": [ + "anonymous", + "certificate" + ], + "type": "string", + "description": "* `anonymous` - anonymous\n* `certificate` - certificate" + }, + "FeedbackResponse": { "type": "object", "properties": { "id": { - "type": "integer" - }, - "kind": { - "$ref": "#/components/schemas/DeliveryKindEnum" - }, - "channel": { - "type": "string" - }, - "feature": { - "type": "string" - }, - "event": { - "type": "string" - }, - "destination_id": { "type": "integer", - "nullable": true - }, - "destination_label": { - "type": "string" - }, - "status": { - "type": "string" - }, - "error": { - "type": "string" - }, - "attempts": { - "type": "integer" + "readOnly": true }, - "retry_generation": { - "type": "integer" + "answers": { + "readOnly": true }, "created_at": { - "type": "string", - "format": "date-time" - }, - "sent_at": { "type": "string", "format": "date-time", - "nullable": true + "readOnly": true + }, + "identity": { + "type": "object", + "additionalProperties": {}, + "nullable": true, + "readOnly": true + }, + "certificate": { + "allOf": [ + { + "$ref": "#/components/schemas/CertificateSummary" + } + ], + "nullable": true, + "readOnly": true } }, "required": [ - "attempts", - "channel", + "answers", + "certificate", "created_at", - "destination_id", - "destination_label", - "error", - "event", - "feature", "id", - "kind", - "retry_generation", - "sent_at", - "status" + "identity" ] }, - "DeliveryEnum": { - "enum": [ - "web", - "device" - ], - "type": "string", - "description": "* `web` - Web\n* `device` - Device" - }, - "DeliveryKindEnum": { - "enum": [ - "email", - "channel" - ], - "type": "string", - "description": "* `email` - email\n* `channel` - channel" - }, - "DeliveryPage": { + "FeedbackResponseList": { "type": "object", "properties": { "count": { @@ -73748,7 +95853,7 @@ "results": { "type": "array", "items": { - "$ref": "#/components/schemas/Delivery" + "$ref": "#/components/schemas/FeedbackResponse" } } }, @@ -73759,397 +95864,518 @@ "results" ] }, - "DeliveryTest": { + "FeedbackSubmission": { "type": "object", "properties": { - "destination_id": { - "type": "integer", - "minimum": 1 + "answers": { + "type": "object", + "additionalProperties": {} + }, + "email": { + "type": "string", + "format": "email" } } }, - "DeliveryTestResult": { + "FeedbackSubmissionResponse": { "type": "object", "properties": { - "kind": { + "thank_you_text": { "type": "string" }, - "id": { - "type": "integer" - }, - "status": { - "type": "string" + "certificate": { + "allOf": [ + { + "$ref": "#/components/schemas/CertificateSummary" + } + ], + "nullable": true } }, "required": [ - "id", - "kind", - "status" + "certificate", + "thank_you_text" ] }, - "DeploymentIdentity": { + "FeedbackSurvey": { "type": "object", "properties": { - "algorithm": { - "type": "string" + "id": { + "type": "integer", + "readOnly": true }, - "deployment_id": { - "type": "string" + "title": { + "type": "string", + "readOnly": true }, - "public_key": { - "type": "string" + "thank_you_text": { + "type": "string", + "readOnly": true }, - "fingerprint": { - "type": "string" + "questions": { + "readOnly": true }, - "age_recipient": { - "type": "string" + "is_open": { + "type": "boolean", + "readOnly": true + }, + "certificate_enabled": { + "type": "boolean", + "readOnly": true + }, + "answered_question_ids": { + "type": "array", + "items": { + "type": "string" + }, + "readOnly": true + }, + "opened_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "closed_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "response_count": { + "type": "integer", + "readOnly": true } }, "required": [ - "age_recipient", - "algorithm", - "deployment_id", - "fingerprint", - "public_key" + "answered_question_ids", + "certificate_enabled", + "closed_at", + "id", + "is_open", + "opened_at", + "questions", + "response_count", + "thank_you_text", + "title" ] }, - "DestinationDispatch": { + "FeedbackSurveyAdminEnvelope": { "type": "object", "properties": { - "detail": { - "type": "string" + "survey": { + "allOf": [ + { + "$ref": "#/components/schemas/FeedbackSurvey" + } + ], + "nullable": true } }, "required": [ - "detail" + "survey" ] }, - "DestinationProbe": { + "FeedbackSurveyWrite": { "type": "object", "properties": { - "ok": { - "type": "boolean" + "title": { + "type": "string", + "maxLength": 200 }, - "error_code": { - "type": "string" + "thank_you_text": { + "type": "string", + "default": "", + "maxLength": 2000 + }, + "questions": {}, + "certificate_enabled": { + "type": "boolean", + "default": false } }, "required": [ - "error_code", - "ok" + "questions", + "title" ] }, - "DestinationScope": { + "FeedbackTemplate": { "type": "object", "properties": { - "machine_type_ids": { + "template": { + "$ref": "#/components/schemas/TemplateEnum" + } + }, + "required": [ + "template" + ] + }, + "FidelityEnum": { + "enum": [ + "REDACTED" + ], + "type": "string", + "description": "* `REDACTED` - Readable — audit metadata and form answers redacted; member contact details included" + }, + "FieldEnum": { + "enum": [ + "name", + "resource", + "booking_window", + "reference" + ], + "type": "string", + "description": "* `name` - name\n* `resource` - resource\n* `booking_window` - booking_window\n* `reference` - reference" + }, + "FieldValidationError": { + "type": "object", + "description": "DRF's field-keyed boundary errors; deliberately not the typed service shape.", + "properties": { + "non_field_errors": { "type": "array", "items": { - "type": "integer" + "type": "string" } }, - "machine_ids": { + "digest": { "type": "array", "items": { - "type": "integer" + "type": "string" } }, - "category_ids": { + "decisions": { "type": "array", "items": { - "type": "integer" + "type": "string" } - } - } - }, - "Device": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "readOnly": true - }, - "makerspace": { - "type": "integer", - "readOnly": true - }, - "kind": { - "allOf": [ - { - "$ref": "#/components/schemas/KindE45Enum" - } - ], - "readOnly": true }, - "name": { - "type": "string", - "readOnly": true + "approval_id": { + "type": "array", + "items": { + "type": "string" + } }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/DeviceStatusEnum" - } - ], - "readOnly": true + "target_age_recipient": { + "type": "array", + "items": { + "type": "string" + } }, - "public_key": { - "type": "string", - "readOnly": true, - "nullable": true + "archive": { + "type": "array", + "items": { + "type": "string" + } }, - "key_fingerprint": { - "type": "string", - "readOnly": true, - "nullable": true + "source_archive_digest": { + "type": "array", + "items": { + "type": "string" + } }, - "key_version": { - "type": "integer", - "readOnly": true + "target_identity": { + "type": "array", + "items": { + "type": "string" + } }, - "last_seen_at": { + "receipt": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "FirstStaffInvite": { + "type": "object", + "properties": { + "email": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "offline_threshold_seconds": { - "type": "integer", - "readOnly": true + "format": "email" }, - "offline_since": { + "role_slug": { + "$ref": "#/components/schemas/RoleSlugEnum" + } + }, + "required": [ + "email", + "role_slug" + ] + }, + "FontEnum": { + "enum": [ + "sans", + "serif" + ], + "type": "string", + "description": "* `sans` - sans\n* `serif` - serif" + }, + "ForgotPasswordRequest": { + "type": "object", + "properties": { + "email": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "format": "email" + } + }, + "required": [ + "email" + ] + }, + "FrontendDomainStatusEnum": { + "enum": [ + "pending", + "verified", + "failed" + ], + "type": "string", + "description": "* `pending` - Pending\n* `verified` - Verified\n* `failed` - Failed" + }, + "GenericAnalyticsReport": { + "type": "object", + "properties": { + "report_key": { + "type": "string" }, - "offline_notified_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "created_by": { - "type": "integer", - "readOnly": true, - "nullable": true + "typed_rows": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } }, - "created_at": { + "meta": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "rows" + ] + }, + "GenericObject": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + } + }, + "GithubBrowserStart": { + "type": "object", + "properties": { + "redirect_uri": { "type": "string", - "format": "date-time", - "readOnly": true + "format": "uri", + "maxLength": 2048 }, - "revoked_at": { + "nonce": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "revoked_by": { - "type": "integer", - "readOnly": true, - "nullable": true + "writeOnly": true, + "pattern": "^[A-Za-z0-9_-]{43,128}$" } }, "required": [ - "created_at", - "created_by", - "id", - "key_fingerprint", - "key_version", - "kind", - "last_seen_at", - "makerspace", - "name", - "offline_notified_at", - "offline_since", - "offline_threshold_seconds", - "public_key", - "revoked_at", - "revoked_by", - "status" + "nonce", + "redirect_uri" ] }, - "DeviceChallengeResponse": { + "GithubBrowserStartResponse": { "type": "object", "properties": { - "challenge": { + "authorization_url": { + "type": "string", + "format": "uri" + }, + "state": { "type": "string" }, "expires_in": { - "type": "integer", - "minimum": 1 + "type": "integer" } }, "required": [ - "challenge", - "expires_in" + "authorization_url", + "expires_in", + "state" ] }, - "DeviceCreate": { + "GrantedBookingEntry": { "type": "object", "properties": { - "kind": { - "$ref": "#/components/schemas/KindE45Enum" + "space_id": { + "type": "integer" + }, + "makerspace_id": { + "type": "integer" + }, + "makerspace_slug": { + "type": "string" }, "name": { - "type": "string", - "maxLength": 120 + "type": "string" }, - "offline_threshold_seconds": { - "type": "integer", - "maximum": 604800, - "minimum": 1, - "default": 300 + "source": { + "$ref": "#/components/schemas/GrantedBookingEntrySourceEnum" + }, + "permissions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Permissions43eEnum" + } } }, "required": [ - "kind", - "name" + "makerspace_id", + "makerspace_slug", + "name", + "permissions", + "source", + "space_id" ] }, - "DeviceCreated": { + "GrantedBookingEntrySourceEnum": { + "enum": [ + "booking_grant" + ], + "type": "string", + "description": "* `booking_grant` - booking_grant" + }, + "GrantedEventEntry": { "type": "object", "properties": { - "device": { - "$ref": "#/components/schemas/Device" + "event_id": { + "type": "integer", + "nullable": true }, - "enrollment": { - "$ref": "#/components/schemas/EnrollmentGrant" + "series_id": { + "type": "integer", + "nullable": true + }, + "makerspace_id": { + "type": "integer" + }, + "makerspace_slug": { + "type": "string" + }, + "title": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/GrantedEventEntrySourceEnum" + }, + "permissions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Permissions5d1Enum" + } } }, "required": [ - "device", - "enrollment" + "event_id", + "makerspace_id", + "makerspace_slug", + "permissions", + "series_id", + "source", + "title" ] }, - "DeviceError": { + "GrantedEventEntrySourceEnum": { + "enum": [ + "event_grant" + ], + "type": "string", + "description": "* `event_grant` - event_grant" + }, + "HardwareRequestError": { "type": "object", "properties": { "detail": { "type": "string" + }, + "code": { + "type": "string" } }, "required": [ + "code", "detail" ] }, - "DeviceGrant": { + "Health": { "type": "object", "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "platform": { - "type": "string" - }, - "app_id": { - "type": "string" - }, - "environment": { - "type": "string" - }, "status": { "type": "string" - }, - "attested_at": { - "type": "string", - "format": "date-time" - }, - "last_used_at": { - "type": "string", - "format": "date-time" - }, - "created_at": { - "type": "string", - "format": "date-time" } }, "required": [ - "app_id", - "attested_at", - "created_at", - "environment", - "id", - "last_used_at", - "platform", "status" ] }, - "DeviceIdentity": { + "HostWaiver": { "type": "object", "properties": { - "platform": { - "$ref": "#/components/schemas/PlatformEnum" + "id": { + "type": "integer", + "readOnly": true }, - "app_id": { + "version": { "type": "string", - "maxLength": 255, - "pattern": "^[A-Za-z0-9._-]{3,255}$" + "readOnly": true }, - "environment": { - "$ref": "#/components/schemas/EnvironmentEnum" + "body": { + "type": "string", + "readOnly": true } }, "required": [ - "app_id", - "environment", - "platform" + "body", + "id", + "version" ] }, - "DeviceLogin": { + "HostWaiverProjection": { "type": "object", "properties": { - "platform": { - "$ref": "#/components/schemas/PlatformEnum" - }, - "app_id": { - "type": "string", - "maxLength": 255, - "pattern": "^[A-Za-z0-9._-]{3,255}$" - }, - "environment": { - "$ref": "#/components/schemas/EnvironmentEnum" - }, - "username": { - "type": "string", - "maxLength": 254 + "id": { + "type": "integer", + "readOnly": true }, - "password": { + "version": { "type": "string", - "writeOnly": true, - "maxLength": 1024 + "readOnly": true }, - "challenge": { + "body": { "type": "string", - "maxLength": 512 - }, - "attestation": {} + "readOnly": true + } }, "required": [ - "app_id", - "attestation", - "challenge", - "environment", - "password", - "platform", - "username" + "body", + "id", + "version" ] }, - "DeviceLoginResult": { - "oneOf": [ - { - "$ref": "#/components/schemas/DeviceTokenResponse" - }, - { - "$ref": "#/components/schemas/MFAChallengeResponse" - } - ] + "HostWaiverStateEnum": { + "enum": [ + "not_required", + "on_file", + "missing" + ], + "type": "string", + "description": "* `not_required` - not_required\n* `on_file` - on_file\n* `missing` - missing" }, - "DeviceLogoutResponse": { + "HostingError": { "type": "object", "properties": { "detail": { @@ -74160,1085 +96386,1294 @@ "detail" ] }, - "DeviceRefresh": { + "IdentityDisclosureDecision": { "type": "object", "properties": { - "refresh": { - "type": "string", - "writeOnly": true, - "maxLength": 4096 + "user_id": { + "type": "integer" + }, + "approved": { + "type": "boolean" } }, "required": [ - "refresh" + "approved", + "user_id" ] }, - "DeviceRefreshResponse": { + "IdentityResolutionEnum": { + "enum": [ + "link_existing", + "create_walk_in" + ], + "type": "string", + "description": "* `link_existing` - Link existing account\n* `create_walk_in` - Create walk-in account" + }, + "ImportCreate": { "type": "object", "properties": { - "access": { - "type": "string" - }, - "refresh": { - "type": "string" + "archive": { + "type": "string", + "format": "uri", + "writeOnly": true }, - "device_grant": { - "$ref": "#/components/schemas/DeviceGrant" + "source_archive_digest": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" } }, "required": [ - "access", - "device_grant", - "refresh" + "archive", + "source_archive_digest" ] }, - "DeviceStatusEnum": { - "enum": [ - "pending", - "active", - "revoked" - ], - "type": "string", - "description": "* `pending` - Pending enrollment\n* `active` - Active\n* `revoked` - Revoked" + "ImportDecisionList": { + "type": "object", + "properties": { + "decisions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImportIdentityDecision" + } + } + }, + "required": [ + "decisions" + ] }, - "DeviceTokenResponse": { + "ImportIdentityDecision": { "type": "object", "properties": { - "access": { - "type": "string" + "source_user_id": { + "type": "string", + "maxLength": 255 }, - "refresh": { - "type": "string" + "identity_resolution": { + "$ref": "#/components/schemas/IdentityResolutionEnum" }, - "user": { - "type": "object", - "additionalProperties": {} + "membership_disposition": { + "$ref": "#/components/schemas/MembershipDispositionEnum" }, - "device_grant": { - "$ref": "#/components/schemas/DeviceGrant" + "target_user_id": { + "type": "integer", + "nullable": true } }, "required": [ - "access", - "device_grant", - "refresh", - "user" + "identity_resolution", + "membership_disposition", + "source_user_id" ] }, - "DirectLoan": { + "ImportJob": { "type": "object", "properties": { - "has_kit_checklist": { - "type": "boolean", - "readOnly": true - }, - "public_token": { + "id": { "type": "string", "format": "uuid", "readOnly": true }, + "source_archive_digest": { + "type": "string", + "maxLength": 64 + }, + "source_makerspace_id": { + "type": "string", + "maxLength": 64 + }, + "source_makerspace_slug": { + "type": "string", + "maxLength": 100 + }, + "source_makerspace_name": { + "type": "string", + "maxLength": 200 + }, + "source_deployment_id": { + "type": "string", + "maxLength": 128 + }, + "storage_mode": { + "type": "string", + "maxLength": 32 + }, "status": { + "$ref": "#/components/schemas/ImportJobStatusEnum" + }, + "identity_count": { "type": "string", "readOnly": true }, - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicToolLoanItem" - }, + "target_lifecycle_state": { + "type": "string", + "nullable": true, "readOnly": true }, - "id": { - "type": "integer", - "readOnly": true + "source_deployment_identity": {}, + "aggregate_outcome": {}, + "failure_code": { + "type": "string", + "maxLength": 64 }, - "target_type": { + "failure_detail": { "type": "string", - "readOnly": true + "maxLength": 500 }, - "target_label": { + "created_at": { "type": "string", + "format": "date-time", "readOnly": true }, - "container_id": { - "type": "integer", + "updated_at": { + "type": "string", + "format": "date-time", "readOnly": true }, - "container_label": { + "expires_at": { "type": "string", - "nullable": true, - "readOnly": true + "format": "date-time" }, - "due_at": { + "terminal_at": { "type": "string", "format": "date-time", - "readOnly": true, "nullable": true }, - "issue_evidence_id": { - "type": "integer", - "readOnly": true, + "scrubbed_at": { + "type": "string", + "format": "date-time", "nullable": true }, - "return_evidence_id": { + "source_retention_notice": { + "type": "string", + "readOnly": true + } + }, + "required": [ + "created_at", + "expires_at", + "id", + "identity_count", + "source_archive_digest", + "source_retention_notice", + "target_lifecycle_state", + "updated_at" + ] + }, + "ImportJobStatusEnum": { + "enum": [ + "pending", + "awaiting_identity", + "ready", + "materializing", + "finalizing", + "completed", + "failed", + "abandoned" + ], + "type": "string", + "description": "* `pending` - Pending\n* `awaiting_identity` - Awaiting identity decisions\n* `ready` - Ready\n* `materializing` - Materializing\n* `finalizing` - Finalizing\n* `completed` - Completed\n* `failed` - Failed\n* `abandoned` - Abandoned" + }, + "ImportRun": { + "type": "object", + "properties": { + "target_identity": { + "type": "object", + "additionalProperties": {} + } + } + }, + "Inbox": { + "type": "object", + "properties": { + "id": { "type": "integer", - "readOnly": true, - "nullable": true + "readOnly": true }, - "return_notes": { + "feature": { "type": "string", "readOnly": true }, - "return_scan_required": { - "type": "boolean", + "event": { + "type": "string", "readOnly": true }, - "source": { + "title": { "type": "string", "readOnly": true }, - "issued_by": { - "allOf": [ - { - "$ref": "#/components/schemas/DirectLoanUserAttribution" - } - ], - "nullable": true, + "summary": { + "type": "string", "readOnly": true }, - "return_items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DirectLoanReturnItem" - }, + "link": { + "type": "string", + "readOnly": true + }, + "read_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", "readOnly": true } }, "required": [ - "container_id", - "container_label", - "due_at", - "has_kit_checklist", + "created_at", + "event", + "feature", "id", - "issue_evidence_id", - "issued_by", - "items", - "public_token", - "return_evidence_id", - "return_items", - "return_notes", - "return_scan_required", - "source", - "status", - "target_label", - "target_type" + "link", + "read_at", + "summary", + "title" ] }, - "DirectLoanIssue": { + "InboxPage": { "type": "object", "properties": { - "kit_checklist": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KitCount" - }, - "maxItems": 3550 - }, - "borrower_id": { - "type": "integer" - }, - "evidence_id": { + "count": { "type": "integer" }, - "remark": { - "type": "string" - }, - "container_id": { - "type": "integer", + "next": { + "type": "string", + "format": "uri", "nullable": true }, - "qr_payloads": { - "type": "array", - "items": { - "type": "string", - "maxLength": 64 - }, - "maxItems": 50 + "previous": { + "type": "string", + "format": "uri", + "nullable": true }, - "items": { + "results": { "type": "array", "items": { - "$ref": "#/components/schemas/DirectLoanItem" + "$ref": "#/components/schemas/Inbox" } } }, "required": [ - "borrower_id", - "evidence_id" + "count", + "next", + "previous", + "results" ] }, - "DirectLoanItem": { + "InheritFieldsEnum": { + "enum": [ + "cancellation_cutoff_hours", + "capacity", + "custom_form", + "description", + "ends_at", + "image_key", + "is_public", + "location", + "location_kind", + "payment_amount", + "registration_audience", + "registration_cutoff_at", + "registration_cutoff_lead_minutes", + "registration_requires_approval", + "starts_at", + "title" + ], + "type": "string", + "description": "* `cancellation_cutoff_hours` - cancellation_cutoff_hours\n* `capacity` - capacity\n* `custom_form` - custom_form\n* `description` - description\n* `ends_at` - ends_at\n* `image_key` - image_key\n* `is_public` - is_public\n* `location` - location\n* `location_kind` - location_kind\n* `payment_amount` - payment_amount\n* `registration_audience` - registration_audience\n* `registration_cutoff_at` - registration_cutoff_at\n* `registration_cutoff_lead_minutes` - registration_cutoff_lead_minutes\n* `registration_requires_approval` - registration_requires_approval\n* `starts_at` - starts_at\n* `title` - title" + }, + "Intake": { "type": "object", "properties": { - "product_id": { - "type": "integer" - }, - "quantity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 1 - } + "intake_schema": {} }, "required": [ - "product_id", - "quantity" + "intake_schema" ] }, - "DirectLoanMember": { + "IntegrationBooking": { "type": "object", "properties": { - "membership_id": { - "type": "integer", - "readOnly": true + "id": { + "type": "integer" }, - "user_id": { - "type": "integer", - "readOnly": true + "resource_id": { + "type": "integer" }, - "display_name": { + "starts_at": { "type": "string", - "readOnly": true + "format": "date-time" }, - "username": { + "ends_at": { "type": "string", - "readOnly": true + "format": "date-time" }, - "is_walk_in": { - "type": "boolean", - "readOnly": true + "status": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "arrived_at": { + "type": "string", + "format": "date-time", + "nullable": true } }, "required": [ "display_name", - "is_walk_in", - "membership_id", - "user_id", - "username" + "ends_at", + "id", + "resource_id", + "starts_at", + "status" ] }, - "DirectLoanReturn": { + "IntegrationBookingPage": { "type": "object", "properties": { - "kit_checklist": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KitCount" - }, - "maxItems": 3550 - }, - "evidence_id": { - "type": "integer" - }, - "notes": { - "type": "string" - }, - "qr_payload": { + "next_cursor": { "type": "string", - "maxLength": 64 + "nullable": true }, - "resolutions": { + "results": { "type": "array", "items": { - "$ref": "#/components/schemas/ReturnItemResolution" + "$ref": "#/components/schemas/IntegrationBooking" } } }, "required": [ - "evidence_id", - "notes" + "next_cursor", + "results" ] }, - "DirectLoanReturnAsset": { + "IntegrationBookingPublicPage": { "type": "object", "properties": { - "asset_id": { - "type": "integer" + "public_url": { + "type": "string", + "format": "uri" }, - "asset_tag": { + "embed_path": { + "type": "string" + }, + "payment_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "currency": { "type": "string" + }, + "rules": { + "$ref": "#/components/schemas/BookingPublicRules" + }, + "sections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IntegrationBookingPublicSection" + } } }, "required": [ - "asset_id", - "asset_tag" + "currency", + "embed_path", + "payment_amount", + "public_url", + "rules", + "sections" ] }, - "DirectLoanReturnItem": { + "IntegrationBookingPublicSection": { "type": "object", "properties": { - "item_id": { - "type": "integer" + "kind": { + "type": "string" }, - "product_name": { + "slug": { "type": "string" }, - "remaining_quantity": { - "type": "integer" + "title": { + "type": "string" }, - "tracking_mode": { + "body": { "type": "string" }, - "assets": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DirectLoanReturnAsset" - } + "url": { + "type": "string" + }, + "position": { + "type": "integer" + }, + "image_url": { + "type": "string", + "nullable": true + }, + "image_alt_text": { + "type": "string" } }, "required": [ - "assets", - "item_id", - "product_name", - "remaining_quantity", - "tracking_mode" + "body", + "image_alt_text", + "image_url", + "kind", + "position", + "slug", + "title", + "url" ] }, - "DirectLoanUserAttribution": { + "IntegrationBookingResource": { "type": "object", "properties": { - "username": { + "id": { + "type": "integer" + }, + "name": { "type": "string" }, - "role": { + "kind": { + "type": "string" + }, + "description": { + "type": "string" + }, + "capacity": { + "type": "integer" + }, + "location": { "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "is_public": { + "type": "boolean" + }, + "public_page": { + "allOf": [ + { + "$ref": "#/components/schemas/IntegrationBookingPublicPage" + } + ], + "nullable": true, + "readOnly": true } }, "required": [ - "role", - "username" + "capacity", + "description", + "id", + "is_active", + "is_public", + "kind", + "location", + "name", + "public_page" ] }, - "Directory": { + "IntegrationBookingResourcePage": { "type": "object", "properties": { - "members": { + "next_cursor": { + "type": "string", + "nullable": true + }, + "results": { "type": "array", "items": { - "$ref": "#/components/schemas/DirectoryEntry" + "$ref": "#/components/schemas/IntegrationBookingResource" } - }, - "hidden_count": { - "type": "integer" } }, "required": [ - "hidden_count", - "members" + "next_cursor", + "results" ] }, - "DirectoryEntry": { + "IntegrationConfiguredHealth": { "type": "object", - "description": "The listing row. Display name and avatar only -- never email, never phone.\n\nOtherwise \"see who else is here\" is an address harvest performed by anyone a space\nadmits, which is a different thing from what the member agreed to publish.", "properties": { - "membership_id": { - "type": "integer" + "status": { + "$ref": "#/components/schemas/Status83eEnum" }, - "display_name": { + "detail": { "type": "string" }, - "headline": { + "configured": { + "type": "boolean" + } + } + }, + "IntegrationDeliveriesByStream": { + "type": "object", + "properties": { + "status": { + "$ref": "#/components/schemas/Status83eEnum" + }, + "detail": { "type": "string" }, - "avatar_url": { + "hardware": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "printing": { "type": "string", + "format": "date-time", "nullable": true } - }, - "required": [ - "avatar_url", - "display_name", - "headline", - "membership_id" - ] - }, - "DispositionEnum": { - "enum": [ - "needs_fix", - "remove" - ], - "type": "string", - "description": "* `needs_fix` - needs_fix\n* `remove` - remove" + } }, - "DocTypeEnum": { - "enum": [ - "manual", - "sop" - ], - "type": "string", - "description": "* `manual` - Manual\n* `sop` - SOP" + "IntegrationEmailHealth": { + "type": "object", + "properties": { + "status": { + "$ref": "#/components/schemas/Status83eEnum" + }, + "detail": { + "type": "string" + }, + "total": { + "type": "integer" + }, + "pending": { + "type": "integer" + }, + "sent": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "stalled": { + "type": "integer" + }, + "last_failure": { + "allOf": [ + { + "$ref": "#/components/schemas/IntegrationLastFailure" + } + ], + "nullable": true + } + } }, - "DocumentFinalize": { + "IntegrationEvent": { "type": "object", "properties": { - "object_key": { - "type": "string", - "maxLength": 255 + "id": { + "type": "integer" }, - "doc_type": { + "title": { + "type": "string" + }, + "status": { + "type": "string" + }, + "starts_at": { "type": "string", - "maxLength": 16 + "format": "date-time" }, - "original_filename": { + "ends_at": { "type": "string", - "maxLength": 255 + "format": "date-time" + }, + "location": { + "type": "string" } }, "required": [ - "doc_type", - "object_key", - "original_filename" + "ends_at", + "id", + "location", + "starts_at", + "status", + "title" ] }, - "DocumentPage": { + "IntegrationEventPage": { "type": "object", "properties": { - "count": { - "type": "integer" - }, - "next": { - "type": "string", - "format": "uri", - "nullable": true - }, - "previous": { + "next_cursor": { "type": "string", - "format": "uri", "nullable": true }, "results": { "type": "array", "items": { - "$ref": "#/components/schemas/PaymentDocument" + "$ref": "#/components/schemas/IntegrationEvent" } } }, "required": [ - "count", - "next", - "previous", + "next_cursor", "results" ] }, - "DocumentPresign": { + "IntegrationHealth": { "type": "object", "properties": { - "filename": { - "type": "string", - "maxLength": 255 + "status": { + "$ref": "#/components/schemas/Status00eEnum" }, - "content_type": { - "type": "string", - "maxLength": 100 + "email": { + "$ref": "#/components/schemas/IntegrationEmailHealth" + }, + "deliveries_by_stream": { + "$ref": "#/components/schemas/IntegrationDeliveriesByStream" + }, + "smtp": { + "$ref": "#/components/schemas/IntegrationConfiguredHealth" + }, + "telegram": { + "$ref": "#/components/schemas/IntegrationConfiguredHealth" + }, + "worker": { + "$ref": "#/components/schemas/IntegrationWorkerHealth" + }, + "chat_destinations": { + "type": "object", + "additionalProperties": {} + }, + "sms": { + "$ref": "#/components/schemas/IntegrationConfiguredHealth" + }, + "native_push": { + "type": "object", + "additionalProperties": {} } }, "required": [ - "content_type", - "filename" + "chat_destinations", + "deliveries_by_stream", + "email", + "native_push", + "sms", + "smtp", + "status", + "telegram", + "worker" ] }, - "DomainVerificationRecord": { + "IntegrationLastFailure": { "type": "object", "properties": { - "host": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "subject": { "type": "string" }, - "type": { + "error": { "type": "string" }, - "value": { + "stream": { "type": "string" } }, "required": [ - "host", - "type", - "value" + "created_at", + "error", + "stream", + "subject" ] }, - "DomainVerificationResponse": { + "IntegrationParticipant": { "type": "object", "properties": { - "status": { - "$ref": "#/components/schemas/StatusA39Enum" + "id": { + "type": "integer" }, - "token": { + "display_name": { "type": "string" }, - "expected_record": { - "allOf": [ - { - "$ref": "#/components/schemas/DomainVerificationRecord" - } - ], - "nullable": true + "registered_at": { + "type": "string", + "format": "date-time" }, - "verified_at": { + "attended_at": { "type": "string", "format": "date-time", "nullable": true - }, - "detail": { - "type": "string" } }, "required": [ - "detail", - "expected_record", - "status", - "token", - "verified_at" + "display_name", + "id", + "registered_at" ] }, - "DownloadGrant": { + "IntegrationParticipantPage": { "type": "object", "properties": { - "url": { - "type": "string" + "next_cursor": { + "type": "string", + "nullable": true }, - "expires_in": { - "type": "integer" + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IntegrationParticipant" + } } }, "required": [ - "expires_in", - "url" + "next_cursor", + "results" ] }, - "EducationEntry": { + "IntegrationWorkerHealth": { "type": "object", "properties": { - "institution": { - "type": "string", - "maxLength": 200 + "status": { + "$ref": "#/components/schemas/Status83eEnum" }, - "qualification": { - "type": "string", - "default": "", - "maxLength": 200 + "detail": { + "type": "string" }, - "year": { + "broker_configured": { + "type": "boolean" + }, + "eager": { + "type": "boolean" + }, + "last_seen": { "type": "string", - "default": "", - "maxLength": 20 + "format": "date-time", + "nullable": true + }, + "stale": { + "type": "boolean" } - }, - "required": [ - "institution" - ] + } }, - "EmailLog": { + "IntegritySourceEnum": { + "enum": [ + "artifact_ledger", + "archive_row" + ], + "type": "string", + "description": "* `artifact_ledger` - Artifact ledger\n* `archive_row` - Archive row" + }, + "IntervalEnum": { + "enum": [ + "monthly", + "yearly", + "custom_days" + ], + "type": "string", + "description": "* `monthly` - Monthly\n* `yearly` - Yearly\n* `custom_days` - Custom number of days" + }, + "InventoryAssetAdmin": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "to_email": { - "type": "string", + "makerspace": { + "type": "integer", "readOnly": true }, - "subject": { - "type": "string", + "product": { + "type": "integer", "readOnly": true }, - "stream": { + "product_name": { "type": "string", "readOnly": true }, - "event": { + "box": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "box_label": { + "type": "string", + "readOnly": true, + "nullable": true + }, + "asset_tag": { "type": "string", "readOnly": true }, - "audience": { + "serial_number": { "type": "string", "readOnly": true }, "status": { "allOf": [ { - "$ref": "#/components/schemas/EmailLogStatusEnum" + "$ref": "#/components/schemas/InventoryAssetStatusEnum" } ], "readOnly": true }, - "error": { + "qr_code_id": { + "type": "integer", + "nullable": true, + "readOnly": true + }, + "qr_payload": { "type": "string", + "nullable": true, "readOnly": true }, - "attempts": { - "type": "integer", + "public_self_checkout_enabled": { + "type": "boolean", "readOnly": true }, - "created_at": { + "notes": { "type": "string", - "format": "date-time", "readOnly": true }, - "sent_at": { + "updated_at": { "type": "string", "format": "date-time", - "readOnly": true, - "nullable": true + "readOnly": true } }, "required": [ - "attempts", - "audience", - "created_at", - "error", - "event", + "asset_tag", + "box", + "box_label", "id", - "sent_at", + "makerspace", + "notes", + "product", + "product_name", + "public_self_checkout_enabled", + "qr_code_id", + "qr_payload", + "serial_number", "status", - "stream", - "subject", - "to_email" + "updated_at" ] }, - "EmailLogStatusEnum": { + "InventoryAssetStatusAction": { + "type": "object", + "properties": { + "action": { + "$ref": "#/components/schemas/InventoryAssetStatusActionActionEnum" + } + }, + "required": [ + "action" + ] + }, + "InventoryAssetStatusActionActionEnum": { "enum": [ - "pending", - "sending", - "sent", - "failed", - "skipped" + "shelve", + "repair" ], "type": "string", - "description": "* `pending` - Pending\n* `sending` - Sending\n* `sent` - Sent\n* `failed` - Failed\n* `skipped` - Skipped" + "description": "* `shelve` - shelve\n* `repair` - repair" }, - "EmailTemplateDetail": { + "InventoryAssetStatusEnum": { + "enum": [ + "available", + "reserved", + "issued", + "damaged", + "lost", + "retired", + "maintenance" + ], + "type": "string", + "description": "* `available` - Available\n* `reserved` - Reserved\n* `issued` - Issued\n* `damaged` - Damaged\n* `lost` - Lost\n* `retired` - Retired\n* `maintenance` - Maintenance" + }, + "InventoryChainOfCustodyResponse": { "type": "object", "properties": { - "stream": { - "type": "string", - "readOnly": true + "product_id": { + "type": "integer" }, - "audience": { - "type": "string", - "readOnly": true + "product_name": { + "type": "string" }, - "key": { - "type": "string", - "readOnly": true + "tracking_mode": { + "type": "string" }, - "label": { - "type": "string", - "readOnly": true + "limit": { + "type": "integer" }, - "description": { - "type": "string", - "readOnly": true + "truncated": { + "type": "boolean" }, - "fields": { + "events": { "type": "array", "items": { - "type": "object", - "additionalProperties": {} - }, - "readOnly": true + "$ref": "#/components/schemas/TimelineEvent" + } }, - "subject": { - "type": "string", - "readOnly": true + "asset_groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssetChainGroup" + } }, - "text_body": { - "type": "string", + "quantity_summary": { + "allOf": [ + { + "$ref": "#/components/schemas/QuantityChainSummary" + } + ], + "nullable": true + } + }, + "required": [ + "asset_groups", + "events", + "limit", + "product_id", + "product_name", + "quantity_summary", + "tracking_mode", + "truncated" + ] + }, + "InventoryProductAdmin": { + "type": "object", + "properties": { + "id": { + "type": "integer", "readOnly": true }, - "html_body": { - "type": "string", + "makerspace": { + "type": "integer", "readOnly": true }, - "is_active": { - "type": "boolean", - "readOnly": true + "box": { + "type": "integer", + "nullable": true }, - "is_overridden": { - "type": "boolean", - "readOnly": true + "category": { + "type": "integer", + "nullable": true }, - "default_subject": { + "name": { "type": "string", - "readOnly": true + "maxLength": 200 }, - "default_text": { + "manufacturer": { "type": "string", - "readOnly": true + "maxLength": 200 }, - "default_html": { - "type": "string", - "readOnly": true - } - }, - "required": [ - "audience", - "default_html", - "default_subject", - "default_text", - "description", - "fields", - "html_body", - "is_active", - "is_overridden", - "key", - "label", - "stream", - "subject", - "text_body" - ] - }, - "EmailTemplateListItem": { - "type": "object", - "properties": { - "stream": { + "model": { "type": "string", - "readOnly": true + "maxLength": 200 }, - "audience": { + "sku": { "type": "string", - "readOnly": true + "maxLength": 100 }, - "key": { + "barcode": { "type": "string", - "readOnly": true + "maxLength": 100 }, - "label": { + "supplier": { "type": "string", - "readOnly": true - }, - "is_active": { - "type": "boolean", - "readOnly": true + "maxLength": 200 }, - "is_overridden": { - "type": "boolean", - "readOnly": true + "reorder_point": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 }, - "can_edit_space_default": { - "type": "boolean", - "readOnly": true + "reorder_quantity": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 }, - "overridable_types": { + "kit_components": { "type": "array", "items": { - "$ref": "#/components/schemas/MachineTypeOption" + "$ref": "#/components/schemas/KitComponent" }, - "readOnly": true - } - }, - "required": [ - "audience", - "can_edit_space_default", - "is_active", - "is_overridden", - "key", - "label", - "overridable_types", - "stream" - ] - }, - "EmailTemplatePreviewRequest": { - "type": "object", - "properties": { - "stream": { - "type": "string" + "maxItems": 50 }, - "audience": { + "description": { "type": "string" }, - "key": { - "type": "string" + "unit_cost": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,4})?$", + "nullable": true }, - "machine_type_id": { - "type": "integer" + "cost_currency": { + "type": "string", + "maxLength": 3 }, - "subject": { + "cost_unit": { "type": "string", - "maxLength": 200 + "maxLength": 16 }, - "text_body": { - "type": "string" + "borrowing_terms": { + "type": "string", + "maxLength": 2000 }, - "html_body": { - "type": "string" - } - }, - "required": [ - "audience", - "html_body", - "key", - "stream", - "subject", - "text_body" - ] - }, - "EmailTemplatePreviewResponse": { - "type": "object", - "properties": { - "subject": { + "image_key": { "type": "string", "readOnly": true }, - "text_body": { + "image_url": { "type": "string", + "format": "uri", + "nullable": true, "readOnly": true }, - "html_body": { - "type": "string", + "tracking_mode": { + "$ref": "#/components/schemas/TrackingModeB86Enum" + }, + "loan_days": { + "type": "integer", + "maximum": 365, + "minimum": 1, + "nullable": true + }, + "max_renewals": { + "type": "integer", + "maximum": 100, + "minimum": 0, + "nullable": true + }, + "total_quantity": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "available_quantity": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "reserved_quantity": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "issued_quantity": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "damaged_quantity": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "lost_quantity": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "needs_fix_quantity": { + "type": "integer", "readOnly": true - } - }, - "required": [ - "html_body", - "subject", - "text_body" - ] - }, - "EmailVerificationConfirm": { - "type": "object", - "properties": { - "code": { - "type": "string", - "maxLength": 6 - } - }, - "required": [ - "code" - ] - }, - "EndReasonEnum": { - "enum": [ - "superseded", - "membership_revoked", - "claim_revoked", - "user_ended" - ], - "type": "string", - "description": "* `superseded` - Superseded\n* `membership_revoked` - Membership revoked\n* `claim_revoked` - Claim revoked\n* `user_ended` - User ended" - }, - "EnrollmentGrant": { - "type": "object", - "description": "Returned exactly once: the raw token is never stored and cannot be re-read.", - "properties": { - "enrollment_id": { - "type": "string", - "format": "uuid" }, - "device_id": { + "is_public": { + "type": "boolean" + }, + "public_self_checkout_enabled": { + "type": "boolean" + }, + "show_public_count": { + "type": "boolean" + }, + "public_availability_mode": { + "$ref": "#/components/schemas/PublicAvailabilityModeFc2Enum" + }, + "storage_location": { "type": "string", - "format": "uuid" + "maxLength": 200 }, - "purpose": { - "$ref": "#/components/schemas/PurposeEnum" + "is_archived": { + "type": "boolean" }, - "token": { - "type": "string" + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true }, - "expires_at": { + "updated_at": { "type": "string", - "format": "date-time" + "format": "date-time", + "readOnly": true } }, - "required": [ - "device_id", - "enrollment_id", - "expires_at", - "purpose", - "token" - ] - }, - "EnvironmentEnum": { - "enum": [ - "development", - "production" - ], - "type": "string", - "description": "* `development` - Development\n* `production` - Production" - }, - "Error": { - "type": "object", - "properties": { - "detail": { - "type": "string" - } - } + "required": [ + "created_at", + "id", + "image_key", + "image_url", + "makerspace", + "name", + "needs_fix_quantity", + "updated_at" + ] }, - "EventAdmin": { + "InventoryProductAdminCreate": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "makerspace_id": { + "makerspace": { "type": "integer", "readOnly": true }, - "ticketing_features": { - "type": "array", - "items": { - "type": "string" - }, - "readOnly": true - }, - "series_summary": { - "type": "object", - "nullable": true, - "properties": { - "id": { - "type": "integer" - }, - "public_token": { - "type": "string", - "format": "uuid" - }, - "title": { - "type": "string" - }, - "timezone": { - "type": "string" - } - }, - "readOnly": true - }, - "series_revision": { + "box": { "type": "integer", - "readOnly": true, "nullable": true }, - "series_override_fields": { - "readOnly": true + "category": { + "type": "integer", + "nullable": true }, - "title": { + "name": { "type": "string", - "readOnly": true + "maxLength": 200 }, - "description": { + "manufacturer": { "type": "string", - "readOnly": true + "maxLength": 200 }, - "starts_at": { + "model": { "type": "string", - "format": "date-time", - "readOnly": true + "maxLength": 200 }, - "ends_at": { + "sku": { "type": "string", - "format": "date-time", - "readOnly": true + "maxLength": 100 }, - "timezone_name": { + "barcode": { "type": "string", - "readOnly": true + "maxLength": 100 }, - "location": { + "supplier": { "type": "string", - "readOnly": true - }, - "location_kind": { - "allOf": [ - { - "$ref": "#/components/schemas/LocationKindEnum" - } - ], - "readOnly": true + "maxLength": 200 }, - "custom_form": { - "readOnly": true, - "nullable": true + "reorder_point": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 }, - "capacity": { + "reorder_quantity": { "type": "integer", - "readOnly": true + "maximum": 2147483647, + "minimum": 0 }, - "payment_amount": { + "kit_components": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KitComponent" + }, + "maxItems": 50 + }, + "description": { + "type": "string" + }, + "unit_cost": { "type": "string", "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "readOnly": true + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,4})?$", + "nullable": true }, - "registration_requires_approval": { - "type": "boolean", - "readOnly": true + "cost_currency": { + "type": "string", + "maxLength": 3 }, - "registration_audience": { - "allOf": [ - { - "$ref": "#/components/schemas/EventDefaultRegistrationAudienceEnum" - } - ], + "cost_unit": { + "type": "string", + "maxLength": 16 + }, + "borrowing_terms": { + "type": "string", + "maxLength": 2000 + }, + "image_key": { + "type": "string", "readOnly": true }, - "category_id": { - "type": "integer", + "image_url": { + "type": "string", + "format": "uri", "nullable": true, "readOnly": true }, - "tags": { - "readOnly": true + "tracking_mode": { + "$ref": "#/components/schemas/TrackingModeB86Enum" }, - "cancellation_cutoff_hours": { + "loan_days": { "type": "integer", - "readOnly": true, + "maximum": 365, + "minimum": 1, "nullable": true }, - "registration_cutoff_at": { - "type": "string", - "format": "date-time", - "readOnly": true, + "max_renewals": { + "type": "integer", + "maximum": 100, + "minimum": 0, "nullable": true }, - "registration_cutoff_lead_minutes": { + "total_quantity": { "type": "integer", - "readOnly": true, - "nullable": true + "maximum": 2147483647, + "minimum": 0 }, - "effective_registration_cutoff_at": { - "type": "string", - "format": "date-time", - "nullable": true, - "readOnly": true + "available_quantity": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 }, - "registration_open": { - "type": "boolean", + "reserved_quantity": { + "type": "integer", "readOnly": true }, - "offline_checkin_enabled": { - "type": "boolean", + "issued_quantity": { + "type": "integer", "readOnly": true }, - "is_public": { - "type": "boolean", + "damaged_quantity": { + "type": "integer", "readOnly": true }, - "image_url": { - "type": "string", - "format": "uri", - "nullable": true, + "lost_quantity": { + "type": "integer", "readOnly": true }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/StatusFbbEnum" - } - ], + "needs_fix_quantity": { + "type": "integer", "readOnly": true }, - "created_by_id": { - "type": "integer", - "readOnly": true, - "nullable": true + "is_public": { + "type": "boolean" + }, + "public_self_checkout_enabled": { + "type": "boolean" + }, + "show_public_count": { + "type": "boolean" + }, + "public_availability_mode": { + "$ref": "#/components/schemas/PublicAvailabilityModeFc2Enum" + }, + "storage_location": { + "type": "string", + "maxLength": 200 + }, + "is_archived": { + "type": "boolean" }, "created_at": { "type": "string", @@ -75249,543 +97684,290 @@ "type": "string", "format": "date-time", "readOnly": true - }, - "registration_counts": { - "allOf": [ - { - "$ref": "#/components/schemas/EventRegistrationCounts" - } - ], - "readOnly": true - }, - "organizers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EventOrganizerSummary" - }, - "readOnly": true } }, "required": [ - "cancellation_cutoff_hours", - "capacity", - "category_id", "created_at", - "created_by_id", - "custom_form", - "description", - "effective_registration_cutoff_at", - "ends_at", + "damaged_quantity", "id", + "image_key", "image_url", - "is_public", - "location", - "location_kind", - "makerspace_id", - "offline_checkin_enabled", - "organizers", - "payment_amount", - "registration_audience", - "registration_counts", - "registration_cutoff_at", - "registration_cutoff_lead_minutes", - "registration_open", - "registration_requires_approval", - "series_override_fields", - "series_revision", - "series_summary", - "starts_at", - "status", - "tags", - "ticketing_features", - "timezone_name", - "title", + "issued_quantity", + "lost_quantity", + "makerspace", + "name", + "needs_fix_quantity", + "reserved_quantity", "updated_at" ] }, - "EventAttendanceMark": { - "type": "object", - "properties": { - "source": { - "allOf": [ - { - "$ref": "#/components/schemas/EventAttendanceMarkSourceEnum" - } - ], - "default": "online" - } - } - }, - "EventAttendanceMarkSourceEnum": { - "enum": [ - "online", - "qr" - ], - "type": "string", - "description": "* `online` - online\n* `qr` - qr" - }, - "EventAttendanceReport": { - "type": "object", - "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "typed_rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EventAttendanceRow" - } - } - }, - "required": [ - "rows", - "typed_rows" - ] - }, - "EventAttendanceRow": { + "InventoryProductAdminUpdate": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" + "id": { + "type": "integer", + "readOnly": true }, - "event_id": { - "type": "integer" + "makerspace": { + "type": "integer", + "readOnly": true }, - "series_id": { + "box": { "type": "integer", "nullable": true }, - "series_title": { - "type": "string" - }, - "series_occurrence_key": { - "type": "string" - }, - "title": { - "type": "string" + "category": { + "type": "integer", + "nullable": true }, - "starts_at": { + "name": { "type": "string", - "format": "date-time" - }, - "status": { - "type": "string" - }, - "capacity": { - "type": "integer" - }, - "registrations": { - "type": "integer" - }, - "confirmed": { - "type": "integer" - }, - "pending_approval": { - "type": "integer" + "maxLength": 200 }, - "registered": { - "type": "integer" + "manufacturer": { + "type": "string", + "maxLength": 200 }, - "waitlisted": { - "type": "integer" + "model": { + "type": "string", + "maxLength": 200 }, - "rejected": { - "type": "integer" + "sku": { + "type": "string", + "maxLength": 100 }, - "cancelled": { - "type": "integer" + "barcode": { + "type": "string", + "maxLength": 100 }, - "attended": { - "type": "integer" + "supplier": { + "type": "string", + "maxLength": 200 }, - "attendance_rate_percent": { - "type": "number", - "format": "double", - "nullable": true + "reorder_point": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 }, - "organizers": { - "type": "string" - } - }, - "required": [ - "attendance_rate_percent", - "attended", - "cancelled", - "capacity", - "confirmed", - "event_id", - "organizers", - "pending_approval", - "registered", - "registrations", - "rejected", - "series_id", - "series_occurrence_key", - "series_title", - "starts_at", - "status", - "title", - "waitlisted" - ] - }, - "EventCheckInResolveRequest": { - "type": "object", - "properties": { - "checkin_token": { - "type": "string" - } - }, - "required": [ - "checkin_token" - ] - }, - "EventCheckInResolveResponse": { - "type": "object", - "properties": { - "registration_id": { - "type": "integer" + "reorder_quantity": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 }, - "name": { - "type": "string" + "kit_components": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KitComponent" + }, + "maxItems": 50 }, - "status": { + "description": { "type": "string" }, - "payment_status": { + "unit_cost": { "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,4})?$", "nullable": true }, - "host_waiver_state": { - "$ref": "#/components/schemas/HostWaiverStateEnum" - }, - "event_status": { - "type": "string" - }, - "confirmable": { - "type": "boolean" - } - }, - "required": [ - "confirmable", - "event_status", - "host_waiver_state", - "name", - "payment_status", - "registration_id", - "status" - ] - }, - "EventCollaborationInbox": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "event_id": { - "type": "integer", - "readOnly": true + "cost_currency": { + "type": "string", + "maxLength": 3 }, - "event_title": { + "cost_unit": { "type": "string", - "readOnly": true + "maxLength": 16 }, - "starts_at": { + "borrowing_terms": { "type": "string", - "format": "date-time", - "readOnly": true + "maxLength": 2000 }, - "ends_at": { + "image_key": { "type": "string", - "format": "date-time", "readOnly": true }, - "host_name": { + "image_url": { "type": "string", + "format": "uri", + "nullable": true, "readOnly": true }, - "host_slug": { - "type": "string", - "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" + "tracking_mode": { + "$ref": "#/components/schemas/TrackingModeB86Enum" + }, + "loan_days": { + "type": "integer", + "maximum": 365, + "minimum": 1, + "nullable": true + }, + "max_renewals": { + "type": "integer", + "maximum": 100, + "minimum": 0, + "nullable": true + }, + "total_quantity": { + "type": "integer", + "readOnly": true }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/StatusB9dEnum" - } - ], + "available_quantity": { + "type": "integer", "readOnly": true }, - "created_at": { - "type": "string", - "format": "date-time", + "reserved_quantity": { + "type": "integer", "readOnly": true }, - "responded_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - } - }, - "required": [ - "created_at", - "ends_at", - "event_id", - "event_title", - "host_name", - "host_slug", - "id", - "responded_at", - "starts_at", - "status" - ] - }, - "EventCollaborationRespond": { - "type": "object", - "properties": { - "accept": { - "type": "boolean" - } - }, - "required": [ - "accept" - ] - }, - "EventCollaborator": { - "type": "object", - "properties": { - "id": { + "issued_quantity": { "type": "integer", "readOnly": true }, - "event_id": { + "damaged_quantity": { "type": "integer", "readOnly": true }, - "makerspace_id": { + "lost_quantity": { "type": "integer", "readOnly": true }, - "makerspace_name": { - "type": "string", + "needs_fix_quantity": { + "type": "integer", "readOnly": true }, - "makerspace_slug": { - "type": "string", - "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" + "is_public": { + "type": "boolean" }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/StatusB9dEnum" - } - ], - "readOnly": true + "public_self_checkout_enabled": { + "type": "boolean" }, - "invited_by_id": { - "type": "integer", - "readOnly": true, - "nullable": true + "show_public_count": { + "type": "boolean" }, - "responded_by_id": { - "type": "integer", - "readOnly": true, - "nullable": true + "public_availability_mode": { + "$ref": "#/components/schemas/PublicAvailabilityModeFc2Enum" + }, + "storage_location": { + "type": "string", + "maxLength": 200 + }, + "is_archived": { + "type": "boolean" }, "created_at": { "type": "string", "format": "date-time", "readOnly": true }, - "responded_at": { + "updated_at": { "type": "string", "format": "date-time", - "readOnly": true, - "nullable": true + "readOnly": true } }, "required": [ + "available_quantity", "created_at", - "event_id", + "damaged_quantity", "id", - "invited_by_id", - "makerspace_id", - "makerspace_name", - "makerspace_slug", - "responded_at", - "responded_by_id", - "status" - ] - }, - "EventCollaboratorReplace": { - "type": "object", - "properties": { - "slugs": { - "type": "array", - "items": { - "type": "string", - "pattern": "^[-a-zA-Z0-9_]+$" - } - } - }, - "required": [ - "slugs" + "image_key", + "image_url", + "issued_quantity", + "lost_quantity", + "makerspace", + "name", + "needs_fix_quantity", + "reserved_quantity", + "total_quantity", + "updated_at" ] }, - "EventDefaultRegistrationAudienceEnum": { - "enum": [ - "members", - "accounts", - "invite" - ], - "type": "string", - "description": "* `members` - Members\n* `accounts` - Authenticated accounts\n* `invite` - Invited accounts" - }, - "EventEligibleMember": { + "InventoryQuantityAdjustment": { "type": "object", - "description": "A picker row. Name and id only — a roster is not a contact export.", "properties": { - "member_id": { + "delta_available": { "type": "integer", - "minimum": 1 + "default": 0 }, - "display_name": { + "delta_damaged": { + "type": "integer", + "default": 0 + }, + "delta_lost": { + "type": "integer", + "default": 0 + }, + "reason": { "type": "string" } }, "required": [ - "display_name", - "member_id" + "reason" ] }, - "EventListResponse": { + "Invitation": { "type": "object", "properties": { - "count": { + "role_id": { "type": "integer" }, - "next": { - "type": "string", - "nullable": true - }, - "previous": { + "invite_email": { "type": "string", - "nullable": true - }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EventAdmin" - } + "format": "email", + "maxLength": 254 } }, "required": [ - "count", - "results" + "invite_email", + "role_id" ] }, - "EventListWithDefaults": { + "InvitationClaimOutcome": { "type": "object", "properties": { - "count": { + "id": { "type": "integer" }, - "next": { - "type": "string", - "nullable": true - }, - "previous": { - "type": "string", - "nullable": true - }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EventAdmin" - } - }, - "default_registration_audience": { - "$ref": "#/components/schemas/EventDefaultRegistrationAudienceEnum" + "outcome": { + "$ref": "#/components/schemas/InvitationClaimOutcomeOutcomeEnum" } }, "required": [ - "count", - "default_registration_audience", - "results" + "id", + "outcome" ] }, - "EventOrganizerList": { - "type": "object", - "properties": { - "organizers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EventOrganizerSummary" - }, - "readOnly": true - } - }, - "required": [ - "organizers" - ] + "InvitationClaimOutcomeOutcomeEnum": { + "enum": [ + "active", + "pending_approval", + "awaiting_vouches", + "awaiting_payment" + ], + "type": "string", + "description": "* `active` - active\n* `pending_approval` - pending_approval\n* `awaiting_vouches` - awaiting_vouches\n* `awaiting_payment` - awaiting_payment" }, - "EventOrganizerReplace": { + "InvitationList": { "type": "object", "properties": { - "organization_ids": { + "invitations": { "type": "array", "items": { - "type": "integer", - "minimum": 1 - }, - "maxItems": 50 - } - }, - "required": [ - "organization_ids" - ] - }, - "EventOrganizerSummary": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "slug": { - "type": "string", - "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" - }, - "name": { - "type": "string", - "readOnly": true + "$ref": "#/components/schemas/ClaimableInvitation" + } } }, "required": [ - "id", - "name", - "slug" + "invitations" ] }, - "EventRegistrationAdmin": { + "InvitationRequest": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "event_id": { - "type": "integer", - "readOnly": true - }, "name": { "type": "string", "readOnly": true @@ -75798,265 +97980,123 @@ "type": "string", "readOnly": true }, - "custom_answers": { - "readOnly": true, - "nullable": true - }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/EventRegistrationAdminStatusEnum" - } - ], - "readOnly": true - }, - "created_at": { + "message": { "type": "string", - "format": "date-time", "readOnly": true }, - "payment": { + "status": { "allOf": [ { - "$ref": "#/components/schemas/StaffPaymentSummary" + "$ref": "#/components/schemas/InvitationRequestStatusEnum" } ], - "nullable": true, "readOnly": true }, - "ticket_type_id": { + "handled_by": { "type": "integer", "readOnly": true, "nullable": true }, - "quoted_amount": { + "handled_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "format": "date-time", "readOnly": true, "nullable": true }, - "discount_amount": { + "created_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "format": "date-time", "readOnly": true } }, "required": [ "created_at", - "custom_answers", - "discount_amount", "email", - "event_id", + "handled_at", + "handled_by", "id", + "message", "name", - "payment", "phone", - "quoted_amount", - "status", - "ticket_type_id" + "status" ] }, - "EventRegistrationAdminStatusEnum": { - "enum": [ - "pending_approval", - "registered", - "waitlisted", - "rejected", - "cancelled", - "attended" - ], - "type": "string", - "description": "* `pending_approval` - Pending approval\n* `registered` - Registered\n* `waitlisted` - Waitlisted\n* `rejected` - Rejected\n* `cancelled` - Cancelled\n* `attended` - Attended" - }, - "EventRegistrationCounts": { + "InvitationRequestAck": { "type": "object", "properties": { - "pending_approval": { - "type": "integer", - "readOnly": true - }, - "registered": { - "type": "integer", - "readOnly": true - }, - "waitlisted": { - "type": "integer", - "readOnly": true - }, - "rejected": { - "type": "integer", - "readOnly": true - }, - "cancelled": { - "type": "integer", - "readOnly": true - }, - "attended": { - "type": "integer", - "readOnly": true + "detail": { + "type": "string" } }, "required": [ - "attended", - "cancelled", - "pending_approval", - "registered", - "rejected", - "waitlisted" + "detail" ] }, - "EventRegistrationListResponse": { + "InvitationRequestCreate": { "type": "object", "properties": { - "count": { - "type": "integer" + "name": { + "type": "string", + "maxLength": 200 }, - "next": { + "email": { "type": "string", - "nullable": true + "format": "email", + "maxLength": 254 }, - "previous": { + "phone": { "type": "string", - "nullable": true + "default": "", + "maxLength": 32 }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EventRegistrationAdmin" - } + "message": { + "type": "string", + "default": "", + "maxLength": 1000 + }, + "website": { + "type": "string", + "writeOnly": true } }, "required": [ - "count", - "results" + "email", + "name" ] }, - "EventSeriesDetail": { + "InvitationRequestStatusEnum": { + "enum": [ + "pending", + "invited", + "declined" + ], + "type": "string", + "description": "* `pending` - Pending\n* `invited` - Invited\n* `declined` - Declined" + }, + "Invite": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "public_token": { - "type": "string", - "format": "uuid", - "readOnly": true - }, - "makerspace_id": { - "type": "integer", - "readOnly": true - }, - "title": { - "type": "string", - "readOnly": true - }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/StatusFbbEnum" - } - ], - "readOnly": true - }, - "recurrence_timezone": { - "type": "string", - "readOnly": true - }, - "dtstart_local_date": { - "type": "string", - "format": "date", - "readOnly": true - }, - "dtstart_local_time": { - "type": "string", - "format": "time", - "readOnly": true - }, - "recurrence_rule": { - "type": "string", - "readOnly": true - }, - "duration_minutes": { - "type": "integer", - "readOnly": true - }, - "revision": { - "type": "integer", - "readOnly": true - }, - "next_occurrence_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "future_occurrence_count": { - "type": "integer", - "readOnly": true - }, - "last_materialized_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "last_generation_error_code": { - "type": "string", - "readOnly": true - }, - "updated_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "description": { - "type": "string" - }, - "location": { - "type": "string", - "maxLength": 255 - }, - "location_kind": { - "$ref": "#/components/schemas/LocationKindEnum" - }, - "custom_form": { - "nullable": true - }, - "capacity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "payment_amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - }, - "registration_requires_approval": { - "type": "boolean" - }, - "registration_audience": { - "$ref": "#/components/schemas/EventDefaultRegistrationAudienceEnum" - }, - "cancellation_cutoff_hours": { - "type": "integer", - "maximum": 8760, - "minimum": 0, - "nullable": true + "max_uses": { + "type": "integer", + "readOnly": true }, - "registration_cutoff_lead_minutes": { + "uses": { "type": "integer", - "maximum": 2147483647, - "minimum": 0, - "nullable": true + "readOnly": true }, - "is_public": { - "type": "boolean" + "expires_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true }, - "created_by_id": { - "type": "integer", + "revoked_at": { + "type": "string", + "format": "date-time", "readOnly": true, "nullable": true }, @@ -76065,35 +98105,51 @@ "format": "date-time", "readOnly": true }, - "image_url": { + "code": { "type": "string", - "format": "uri", - "readOnly": true + "readOnly": true, + "description": "Returned once on creation only." } }, "required": [ + "code", "created_at", - "created_by_id", - "dtstart_local_date", - "dtstart_local_time", - "duration_minutes", - "future_occurrence_count", + "expires_at", "id", - "image_url", - "last_generation_error_code", - "last_materialized_at", - "makerspace_id", - "next_occurrence_at", - "public_token", - "recurrence_rule", - "recurrence_timezone", - "revision", - "status", - "title", - "updated_at" + "max_uses", + "revoked_at", + "uses" ] }, - "EventSeriesListResponse": { + "InviteInput": { + "type": "object", + "properties": { + "email": { + "writeOnly": true, + "oneOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "max_uses": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "expires_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + }, + "InviteList": { "type": "object", "properties": { "count": { @@ -76101,16 +98157,18 @@ }, "next": { "type": "string", + "format": "uri", "nullable": true }, "previous": { "type": "string", + "format": "uri", "nullable": true }, "results": { "type": "array", "items": { - "$ref": "#/components/schemas/EventSeriesSummary" + "$ref": "#/components/schemas/Invite" } } }, @@ -76121,1867 +98179,1939 @@ "results" ] }, - "EventSeriesMutationResponse": { + "IssueReject": { "type": "object", "properties": { - "series": { + "item_id": { + "type": "integer" + }, + "broken": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "disposition": { "allOf": [ { - "$ref": "#/components/schemas/EventSeriesDetail" + "$ref": "#/components/schemas/DispositionEnum" } ], - "readOnly": true - }, - "created_occurrence_ids": { + "default": "needs_fix" + } + }, + "required": [ + "item_id" + ] + }, + "IssueRequest": { + "type": "object", + "properties": { + "kit_checklist": { "type": "array", "items": { - "type": "integer" + "$ref": "#/components/schemas/KitCount" }, - "readOnly": true + "maxItems": 3550 }, - "removed_occurrence_ids": { + "evidence_id": { + "type": "integer" + }, + "remark": { + "type": "string", + "default": "" + }, + "asset_qr_payloads": { "type": "array", "items": { - "type": "integer" - }, - "readOnly": true + "type": "string" + } }, - "affected_count": { - "type": "integer", - "readOnly": true + "rejects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssueReject" + } } }, "required": [ - "affected_count", - "created_occurrence_ids", - "removed_occurrence_ids", - "series" + "evidence_id" ] }, - "EventSeriesSummary": { + "Job": { "type": "object", "properties": { "id": { - "type": "integer", + "type": "string", + "format": "uuid", "readOnly": true }, - "public_token": { + "command": { "type": "string", "format": "uuid", "readOnly": true }, - "makerspace_id": { + "makerspace": { "type": "integer", "readOnly": true }, - "title": { + "device": { "type": "string", + "format": "uuid", "readOnly": true }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/StatusFbbEnum" - } - ], - "readOnly": true + "machine": { + "type": "integer", + "readOnly": true, + "nullable": true }, - "recurrence_timezone": { + "service_request": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "machine_name": { "type": "string", "readOnly": true }, - "dtstart_local_date": { + "service_request_name": { "type": "string", - "format": "date", "readOnly": true }, - "dtstart_local_time": { + "external_job_id": { "type": "string", - "format": "time", "readOnly": true }, - "recurrence_rule": { + "state": { + "allOf": [ + { + "$ref": "#/components/schemas/JobStateEnum" + } + ], + "readOnly": true, + "default": "awaiting" + }, + "telemetry_state": { "type": "string", "readOnly": true }, - "duration_minutes": { + "progress_percent": { "type": "integer", + "readOnly": true, + "nullable": true + }, + "telemetry_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "terminal_operation_id": { + "type": "string", + "format": "uuid", + "readOnly": true, + "nullable": true + }, + "outcome": { + "allOf": [ + { + "$ref": "#/components/schemas/JobOutcomeEnum" + } + ], "readOnly": true }, - "revision": { + "actual_minutes": { "type": "integer", - "readOnly": true + "readOnly": true, + "nullable": true }, - "next_occurrence_at": { + "failure_reason": { "type": "string", - "format": "date-time", "readOnly": true }, - "future_occurrence_count": { + "percent_complete": { "type": "integer", - "readOnly": true + "readOnly": true, + "nullable": true }, - "last_materialized_at": { + "actual_grams": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true, + "nullable": true + }, + "actual_quantity": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true, + "nullable": true + }, + "reported_at": { "type": "string", "format": "date-time", "readOnly": true, "nullable": true }, - "last_generation_error_code": { + "applied_at": { "type": "string", - "readOnly": true + "format": "date-time", + "readOnly": true, + "nullable": true }, - "updated_at": { + "processed_by": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { "type": "string", "format": "date-time", "readOnly": true + }, + "consumptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/JobConsumption" + }, + "readOnly": true } }, "required": [ - "dtstart_local_date", - "dtstart_local_time", - "duration_minutes", - "future_occurrence_count", + "actual_grams", + "actual_minutes", + "actual_quantity", + "applied_at", + "command", + "consumptions", + "created_at", + "device", + "external_job_id", + "failure_reason", "id", - "last_generation_error_code", - "last_materialized_at", - "makerspace_id", - "next_occurrence_at", - "public_token", - "recurrence_rule", - "recurrence_timezone", - "revision", - "status", - "title", - "updated_at" + "machine", + "machine_name", + "makerspace", + "outcome", + "percent_complete", + "processed_by", + "progress_percent", + "reported_at", + "service_request", + "service_request_name", + "state", + "telemetry_at", + "telemetry_state", + "terminal_operation_id" ] }, - "EventSeriesWrite": { + "JobConsumption": { "type": "object", "properties": { - "registration_audience": { - "$ref": "#/components/schemas/EventSeriesWriteRegistrationAudienceEnum" - }, - "ticket_types": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TicketInput" - }, - "writeOnly": true - }, - "title": { - "type": "string", - "maxLength": 200 - }, - "description": { - "type": "string", - "default": "" - }, - "location": { - "type": "string", - "default": "", - "maxLength": 255 - }, - "location_kind": { - "allOf": [ - { - "$ref": "#/components/schemas/LocationKindEnum" - } - ], - "default": "other" - }, - "custom_form": { + "machine_consumable": { + "type": "integer", "nullable": true }, - "capacity": { + "machine_consumable_id_snapshot": { "type": "integer", + "maximum": 9223372036854775807, "minimum": 0, - "default": 0 + "format": "int64" }, - "payment_amount": { + "quantity": { "type": "string", "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "default": "0.00" - }, - "registration_requires_approval": { - "type": "boolean", - "default": false - }, - "cancellation_cutoff_hours": { - "type": "integer", - "maximum": 8760, - "minimum": 0, - "nullable": true + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + } + }, + "required": [ + "machine_consumable_id_snapshot", + "quantity" + ] + }, + "JobCreate": { + "type": "object", + "properties": { + "binding_id": { + "type": "string", + "format": "uuid" }, - "registration_cutoff_lead_minutes": { + "service_request_id": { "type": "integer", - "minimum": 0, - "nullable": true - }, - "is_public": { - "type": "boolean", - "default": false + "minimum": 1 }, - "recurrence_timezone": { + "external_job_id": { "type": "string", - "maxLength": 64 + "maxLength": 120 + } + }, + "required": [ + "binding_id", + "external_job_id", + "service_request_id" + ] + }, + "JobOutcomeEnum": { + "enum": [ + "completed", + "failed" + ], + "type": "string", + "description": "* `completed` - Completed\n* `failed` - Failed" + }, + "JobStateEnum": { + "enum": [ + "awaiting", + "reported", + "applied", + "needs_review", + "cancelled" + ], + "type": "string", + "description": "* `awaiting` - Awaiting\n* `reported` - Reported\n* `applied` - Applied\n* `needs_review` - Needs review\n* `cancelled` - Cancelled" + }, + "JoinModeEnum": { + "enum": [ + "open", + "approval", + "invite_only", + "vouch", + "closed" + ], + "type": "string", + "description": "* `open` - Open\n* `approval` - Approval\n* `invite_only` - Invite only\n* `vouch` - Vouch\n* `closed` - Closed" + }, + "JournalLine": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer" }, - "dtstart_local_date": { + "date": { "type": "string", "format": "date" }, - "dtstart_local_time": { - "type": "string", - "format": "time" + "document_number": { + "type": "string" }, - "recurrence_rule": { - "type": "string", - "maxLength": 500 + "kind": { + "type": "string" }, - "duration_minutes": { - "type": "integer", - "minimum": 1 + "payment_id": { + "type": "integer" }, - "effective_from": { + "subject_type": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "account_code": { + "type": "string" + }, + "debit": { "type": "string", - "format": "date-time", - "writeOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + }, + "credit": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + }, + "settlement_method": { + "type": "string" } }, "required": [ - "dtstart_local_date", - "dtstart_local_time", - "duration_minutes", - "recurrence_rule", - "recurrence_timezone", - "title" + "account_code", + "credit", + "currency", + "date", + "debit", + "document_number", + "kind", + "payment_id", + "settlement_method", + "subject_type" ] }, - "EventSeriesWriteRegistrationAudienceEnum": { + "Key292Enum": { + "enum": [ + "in_app", + "email", + "telegram", + "slack", + "mattermost", + "discord", + "webhook", + "native_push", + "sms", + "whatsapp" + ], + "type": "string", + "description": "* `in_app` - Inbox\n* `email` - Email\n* `telegram` - Telegram\n* `slack` - Slack\n* `mattermost` - Mattermost\n* `discord` - Discord\n* `webhook` - Signed webhook\n* `native_push` - Native push\n* `sms` - SMS\n* `whatsapp` - WhatsApp" + }, + "Key4feEnum": { + "enum": [ + "hardware_requests", + "printing", + "events", + "bookings", + "maintenance", + "members", + "api_access", + "devices", + "payments", + "stock", + "certifications" + ], + "type": "string", + "description": "* `hardware_requests` - Hardware requests\n* `printing` - Printing\n* `events` - Events\n* `bookings` - Bookings\n* `maintenance` - Maintenance\n* `members` - Members\n* `api_access` - API access\n* `devices` - Devices\n* `payments` - Payments\n* `stock` - Stock and procurement\n* `certifications` - Certifications" + }, + "Kind21fEnum": { + "enum": [ + "intro", + "logo", + "speaker", + "track", + "room", + "sponsor", + "faq", + "resource", + "highlight" + ], + "type": "string", + "description": "* `intro` - Introduction\n* `logo` - Page logo\n* `speaker` - Speaker\n* `track` - Track\n* `room` - Room\n* `sponsor` - Sponsor\n* `faq` - FAQ\n* `resource` - Resource\n* `highlight` - Highlight" + }, + "Kind3bfEnum": { + "enum": [ + "dev_room", + "bench", + "meeting", + "other" + ], + "type": "string", + "description": "* `dev_room` - Development room\n* `bench` - Bench\n* `meeting` - Meeting room\n* `other` - Other" + }, + "KindAf4Enum": { + "enum": [ + "rules", + "amenity", + "faq", + "link", + "sponsor", + "gallery" + ], + "type": "string", + "description": "* `rules` - Rules\n* `amenity` - Amenity\n* `faq` - FAQ\n* `link` - Link\n* `sponsor` - Sponsor\n* `gallery` - Gallery" + }, + "KindB02Enum": { + "enum": [ + "rollback_in_place", + "disaster" + ], + "type": "string", + "description": "* `rollback_in_place` - Rollback in place\n* `disaster` - Disaster or cross-server" + }, + "KindE45Enum": { + "enum": [ + "printer", + "label_printer", + "generic" + ], + "type": "string", + "description": "* `printer` - Printer\n* `label_printer` - Label printer\n* `generic` - Generic" + }, + "KindE56Enum": { "enum": [ + "role", + "requester", "members", - "accounts", - "invite" + "user" ], "type": "string", - "description": "* `members` - members\n* `accounts` - accounts\n* `invite` - invite" + "description": "* `role` - Role\n* `requester` - Requester\n* `members` - All members\n* `user` - Named user" }, - "EventStaffRegistration": { + "KioskAssignBox": { "type": "object", - "description": "Staff registering an identified account under the event's audience rules.\n\n`member_id` only. Contact details are copied off the account by the registration\nservice, so a staffer cannot record an attendee under a name and email that belong\nto nobody — which is what makes the attendee list usable as an accountability record\nrather than free text.", "properties": { - "member_id": { - "type": "integer", - "minimum": 1 - }, - "invite_code": { - "type": "string", - "writeOnly": true, - "maxLength": 128 - }, - "ticket_type_id": { + "request_id": { "type": "integer", "minimum": 1 }, - "promo_code": { - "type": "string", - "writeOnly": true, - "maxLength": 128 - }, - "custom_answers": { - "nullable": true - }, - "phone": { - "type": "string", - "default": "", - "maxLength": 32 - }, - "email": { - "oneOf": [ - { - "type": "string", - "format": "email", - "default": "" - }, - { - "type": "string", - "maxLength": 0 - } - ] + "box_code": { + "type": "string" } }, "required": [ - "member_id" + "box_code", + "request_id" ] }, - "EventWrite": { + "KioskEvidencePrepare": { "type": "object", "properties": { - "registration_audience": { - "$ref": "#/components/schemas/EventDefaultRegistrationAudienceEnum" - }, - "category_id": { - "type": "integer", - "minimum": 1, - "nullable": true - }, - "tags": { - "type": "array", - "items": { - "type": "string", - "maxLength": 50 - }, - "maxItems": 20 - }, - "title": { - "type": "string", - "maxLength": 200 - }, - "description": { - "type": "string", - "default": "" - }, - "starts_at": { - "type": "string", - "format": "date-time" - }, - "ends_at": { - "type": "string", - "format": "date-time" - }, - "timezone_name": { - "type": "string", - "maxLength": 64 - }, - "location": { - "type": "string", - "default": "", - "maxLength": 255 - }, - "location_kind": { - "allOf": [ - { - "$ref": "#/components/schemas/LocationKindEnum" - } - ], - "default": "other" - }, - "custom_form": { - "nullable": true - }, - "capacity": { - "type": "integer", - "minimum": 0, - "default": 0 - }, - "payment_amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "default": "0.00" - }, - "is_public": { - "type": "boolean", - "default": false - }, - "registration_requires_approval": { - "type": "boolean", - "default": false - }, - "cancellation_cutoff_hours": { - "type": "integer", - "maximum": 8760, - "minimum": 0, - "nullable": true, - "description": "Member cancellation closes this many hours before start; null means start." + "evidence_type": { + "$ref": "#/components/schemas/EvidenceUrlRequestEvidenceTypeEnum" }, - "registration_cutoff_at": { - "type": "string", - "format": "date-time", - "nullable": true + "content_type": { + "type": "string" }, - "registration_cutoff_lead_minutes": { + "size_bytes": { "type": "integer", "minimum": 0, "nullable": true - }, - "inherit_fields": { - "type": "array", - "items": { - "$ref": "#/components/schemas/InheritFieldsEnum" - }, - "writeOnly": true } }, "required": [ - "ends_at", - "starts_at", - "title" + "content_type", + "evidence_type" ] }, - "EvidenceGetResponse": { + "KioskOnlineCommand": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" + "version": { + "$ref": "#/components/schemas/VersionEnum" }, - "expires_in": { - "type": "integer" + "kind": { + "$ref": "#/components/schemas/KioskOnlineCommandKindEnum" + }, + "payload": { + "type": "object", + "additionalProperties": {} } }, "required": [ - "expires_in", - "url" + "kind", + "payload", + "version" ] }, - "EvidenceRetention": { + "KioskOnlineCommandKindEnum": { + "enum": [ + "assign_box", + "direct_handout", + "direct_return", + "resolve_qr", + "reviewed_issue", + "reviewed_return" + ], + "type": "string", + "description": "* `assign_box` - assign_box\n* `direct_handout` - direct_handout\n* `direct_return` - direct_return\n* `resolve_qr` - resolve_qr\n* `reviewed_issue` - reviewed_issue\n* `reviewed_return` - reviewed_return" + }, + "KioskOnlineCommandResult": { "type": "object", - "description": "Retention coordination only: ``null`` on the event means no state row exists,\nwhich is NOT a claim that the bytes are available.", "properties": { - "state": { - "$ref": "#/components/schemas/EvidenceRetentionStateEnum" + "kind": { + "type": "string" }, - "object_expired_at": { - "type": "string", - "format": "date-time", - "nullable": true + "result": { + "type": "object", + "additionalProperties": {} } }, "required": [ - "object_expired_at", - "state" + "kind", + "result" ] }, - "EvidenceRetentionPolicy": { + "KioskOperatorLease": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" - }, - "platform_default_days": { - "type": "integer" - }, - "override_days": { - "type": "integer", - "nullable": true - }, - "effective_days": { - "type": "integer" + "lease_token": { + "type": "string" }, - "object_expiry_enabled": { - "type": "boolean" + "lease_id": { + "type": "string", + "format": "uuid" }, - "abandoned_upload_cleanup_enabled": { - "type": "boolean" + "session_id": { + "type": "string", + "format": "uuid" }, - "abandoned_upload_platform_default_days": { + "actor_id": { "type": "integer" }, - "abandoned_upload_override_days": { - "type": "integer", - "nullable": true + "expires_at": { + "type": "string", + "format": "date-time" }, - "abandoned_upload_effective_days": { - "type": "integer" + "allowed_actions": { + "type": "array", + "items": { + "type": "string" + } }, - "abandoned_upload_global_enabled": { - "type": "boolean" + "permitted_operations": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ - "abandoned_upload_cleanup_enabled", - "abandoned_upload_effective_days", - "abandoned_upload_global_enabled", - "abandoned_upload_override_days", - "abandoned_upload_platform_default_days", - "effective_days", - "makerspace_id", - "object_expiry_enabled", - "override_days", - "platform_default_days" + "actor_id", + "allowed_actions", + "expires_at", + "lease_id", + "lease_token", + "permitted_operations", + "session_id" ] }, - "EvidenceRetentionPreviewRequest": { - "type": "object", - "properties": { - "limit": { - "type": "integer", - "maximum": 1000, - "minimum": 1, - "default": 100 - } - } - }, - "EvidenceRetentionPreviewResponse": { + "KioskStation": { "type": "object", "properties": { - "as_of": { - "type": "string", - "format": "date-time" - }, - "policy_days": { + "id": { "type": "integer" }, - "cutoff": { - "type": "string", - "format": "date-time" - }, - "object_candidates": { - "type": "integer" + "name": { + "type": "string" }, - "candidate_bytes": { - "type": "integer" + "kind": { + "type": "string" }, - "has_more": { + "enabled": { "type": "boolean" }, - "abandoned_upload_cutoff": { + "public_token": { "type": "string", - "format": "date-time", + "format": "uuid", "nullable": true }, - "abandoned_upload_candidates": { - "type": "integer" - }, - "abandoned_upload_candidate_bytes": { - "type": "integer" + "version": { + "type": "integer", + "nullable": true }, - "abandoned_upload_has_more": { - "type": "boolean" + "station_url": { + "type": "string", + "nullable": true }, - "late_write_receipts": { - "type": "integer" + "rotated_at": { + "type": "string", + "format": "date-time", + "nullable": true }, - "late_write_receipts_due": { - "type": "integer" + "created_at": { + "type": "string", + "format": "date-time" } }, "required": [ - "abandoned_upload_candidate_bytes", - "abandoned_upload_candidates", - "abandoned_upload_cutoff", - "abandoned_upload_has_more", - "as_of", - "candidate_bytes", - "cutoff", - "has_more", - "late_write_receipts", - "late_write_receipts_due", - "object_candidates", - "policy_days" + "created_at", + "enabled", + "id", + "kind", + "name", + "public_token", + "rotated_at", + "station_url", + "version" ] }, - "EvidenceRetentionStateEnum": { - "enum": [ - "expiring", - "expired" - ], - "type": "string", - "description": "* `expiring` - expiring\n* `expired` - expired" - }, - "EvidenceType740Enum": { - "enum": [ - "issue", - "return" - ], - "type": "string", - "description": "* `issue` - issue\n* `return` - return" - }, - "EvidenceUrlRequest": { + "KioskStationBootstrap": { "type": "object", "properties": { - "evidence_type": { - "$ref": "#/components/schemas/EvidenceUrlRequestEvidenceTypeEnum" + "station": { + "$ref": "#/components/schemas/_StationRef" }, - "content_type": { - "type": "string" + "makerspace": { + "$ref": "#/components/schemas/_MakerspaceRef" }, - "size_bytes": { - "type": "integer", - "minimum": 0, - "nullable": true + "session_expires_at": { + "type": "string", + "format": "date-time" + }, + "permitted_operations": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ - "content_type", - "evidence_type" + "makerspace", + "permitted_operations", + "session_expires_at", + "station" ] }, - "EvidenceUrlRequestEvidenceTypeEnum": { + "KioskStationCreate": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 160 + }, + "kind": { + "$ref": "#/components/schemas/KioskStationCreateKindEnum" + } + }, + "required": [ + "kind", + "name" + ] + }, + "KioskStationCreateKindEnum": { "enum": [ - "issue", - "return" + "scanner", + "handover" ], "type": "string", - "description": "* `issue` - Issue\n* `return` - Return" + "description": "* `scanner` - Scanner\n* `handover` - Handover" }, - "EvidenceUrlResponse": { + "KioskStationPin": { "type": "object", "properties": { - "evidence_id": { - "type": "integer" - }, - "upload_url": { + "pin": { "type": "string", - "format": "uri" - }, - "fields": { - "type": "object", - "additionalProperties": {} - }, - "object_key": { - "type": "string" - }, - "method": { - "type": "string" + "writeOnly": true, + "pattern": "^\\d{8}$" }, - "headers": { - "type": "object", - "additionalProperties": {} + "makerspace_slug": { + "type": "string", + "maxLength": 80, + "pattern": "^[-a-zA-Z0-9_]+$" } }, "required": [ - "evidence_id", - "fields", - "object_key", - "upload_url" + "pin" ] }, - "FabLabHealthReport": { + "KioskStationReveal": { "type": "object", "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "typed_rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FabLabHealthRow" - } + "current_password": { + "type": "string", + "writeOnly": true + } + }, + "required": [ + "current_password" + ] + }, + "KioskStationRevealResponse": { + "type": "object", + "properties": { + "pin": { + "type": "string" + }, + "version": { + "type": "integer" } }, "required": [ - "rows", - "typed_rows" + "pin", + "version" ] }, - "FabLabHealthRow": { + "KioskStationSecret": { "type": "object", "properties": { - "makerspace_id": { + "id": { "type": "integer" }, - "events_enabled": { - "type": "boolean" + "name": { + "type": "string" }, - "events_available": { + "kind": { + "type": "string" + }, + "enabled": { "type": "boolean" }, - "events_in_period": { - "type": "integer", + "public_token": { + "type": "string", + "format": "uuid", "nullable": true }, - "events_registrations": { + "version": { "type": "integer", "nullable": true }, - "events_attended": { - "type": "integer", + "station_url": { + "type": "string", "nullable": true }, - "events_completed_attendance_rate_percent": { - "type": "number", - "format": "double", + "rotated_at": { + "type": "string", + "format": "date-time", "nullable": true }, - "bookings_enabled": { - "type": "boolean" - }, - "bookings_available": { - "type": "boolean" - }, - "bookings_active_spaces": { - "type": "integer", - "nullable": true + "created_at": { + "type": "string", + "format": "date-time" }, - "bookings_non_cancelled": { - "type": "integer", - "nullable": true + "pin": { + "type": "string" + } + }, + "required": [ + "created_at", + "enabled", + "id", + "kind", + "name", + "pin", + "public_token", + "rotated_at", + "station_url", + "version" + ] + }, + "KioskWorkLine": { + "type": "object", + "properties": { + "item_id": { + "type": "integer" }, - "bookings_reserved_hours": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", - "nullable": true + "label": { + "type": "string" }, - "bookings_upcoming": { - "type": "integer", - "nullable": true + "quantity": { + "type": "integer" }, - "bookings_no_shows": { - "type": "integer", - "nullable": true + "returned": { + "type": "integer" + } + }, + "required": [ + "item_id", + "label", + "quantity", + "returned" + ] + }, + "KioskWorkPackage": { + "type": "object", + "properties": { + "kind": { + "type": "string" }, - "bookings_reservation_utilization_percent": { - "type": "number", - "format": "double", - "nullable": true + "id": { + "type": "integer" }, - "machines_enabled": { - "type": "boolean" + "status": { + "type": "string" }, - "machines_available": { - "type": "boolean" + "display_label": { + "type": "string" }, - "machines_active": { - "type": "integer", - "nullable": true + "person_display": { + "type": "string" }, - "machines_usage_hours": { + "box": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", "nullable": true }, - "maintenance_enabled": { - "type": "boolean" + "lines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KioskWorkLine" + } }, - "maintenance_available": { - "type": "boolean" + "kit_components": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KitExpectation" + } }, - "maintenance_logs": { - "type": "integer", - "nullable": true + "return_resolutions": { + "type": "array", + "items": { + "type": "string" + } }, - "maintenance_total_cost": { + "expires_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", - "nullable": true + "format": "date-time" }, - "maintenance_overdue_schedules": { - "type": "integer", - "nullable": true + "command_age_limit_seconds": { + "type": "integer" + }, + "evidence_max_bytes": { + "type": "integer" } }, "required": [ - "bookings_active_spaces", - "bookings_available", - "bookings_enabled", - "bookings_no_shows", - "bookings_non_cancelled", - "bookings_reservation_utilization_percent", - "bookings_reserved_hours", - "bookings_upcoming", - "events_attended", - "events_available", - "events_completed_attendance_rate_percent", - "events_enabled", - "events_in_period", - "events_registrations", - "machines_active", - "machines_available", - "machines_enabled", - "machines_usage_hours", - "maintenance_available", - "maintenance_enabled", - "maintenance_logs", - "maintenance_overdue_schedules", - "maintenance_total_cost" + "box", + "command_age_limit_seconds", + "display_label", + "evidence_max_bytes", + "expires_at", + "id", + "kind", + "kit_components", + "lines", + "person_display", + "return_resolutions", + "status" ] }, - "FailedDeliveries": { + "KioskWorkPackageRequest": { "type": "object", "properties": { - "email": { - "type": "integer" + "kind": { + "$ref": "#/components/schemas/KioskWorkPackageRequestKindEnum" }, - "notifications": { - "type": "integer" + "id": { + "type": "integer", + "minimum": 1 } }, "required": [ - "email", - "notifications" + "id", + "kind" ] }, - "FailureCodeEnum": { + "KioskWorkPackageRequestKindEnum": { "enum": [ - "deadline_exceeded", - "integrity_error", - "storage_error", - "quota_exceeded", - "internal_error" + "reviewed_request", + "direct_loan", + "direct_handout" ], "type": "string", - "description": "* `` - None\n* `deadline_exceeded` - Deadline exceeded\n* `integrity_error` - Integrity error\n* `storage_error` - Storage error\n* `quota_exceeded` - Quota exceeded\n* `internal_error` - Internal error" + "description": "* `reviewed_request` - reviewed_request\n* `direct_loan` - direct_loan\n* `direct_handout` - direct_handout" }, - "Feature4feEnum": { - "enum": [ - "hardware_requests", - "printing", - "events", - "bookings", - "maintenance", - "members", - "api_access", - "devices", - "payments", - "stock", - "certifications" - ], - "type": "string", - "description": "* `hardware_requests` - Hardware requests\n* `printing` - Printing\n* `events` - Events\n* `bookings` - Bookings\n* `maintenance` - Maintenance\n* `members` - Members\n* `api_access` - API access\n* `devices` - Devices\n* `payments` - Payments\n* `stock` - Stock and procurement\n* `certifications` - Certifications" + "KitComponent": { + "type": "object", + "properties": { + "key": { + "type": "string", + "format": "uuid" + }, + "label": { + "type": "string", + "maxLength": 200 + }, + "quantity": { + "type": "integer", + "maximum": 999, + "minimum": 1 + } + }, + "required": [ + "label", + "quantity" + ] }, - "Feature9d8Enum": { - "enum": [ - "hardware_requests", - "printing", - "events", - "bookings", - "maintenance", - "members", - "api_access", - "devices", - "payments", - "stock", - "certifications" - ], - "type": "string", - "description": "* `hardware_requests` - hardware_requests\n* `printing` - printing\n* `events` - events\n* `bookings` - bookings\n* `maintenance` - maintenance\n* `members` - members\n* `api_access` - api_access\n* `devices` - devices\n* `payments` - payments\n* `stock` - stock\n* `certifications` - certifications" + "KitCount": { + "type": "object", + "properties": { + "key": { + "type": "string", + "format": "uuid" + }, + "present": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + } + }, + "required": [ + "key", + "present" + ] }, - "FeedIssued": { + "KitExpectation": { "type": "object", "properties": { - "feed_url": { + "key": { "type": "string", - "format": "uri" + "format": "uuid" }, - "token_hint": { + "label": { + "type": "string" + }, + "owner_label": { "type": "string" + }, + "quantity": { + "type": "integer" + }, + "issue_present": { + "type": "integer" } }, "required": [ - "feed_url", - "token_hint" + "key", + "label", + "owner_label", + "quantity" ] }, - "FeedState": { + "KitPreview": { "type": "object", "properties": { - "enabled": { - "type": "boolean" - }, - "token_hint": { - "type": "string", - "nullable": true + "components": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KitExpectation" + } } }, "required": [ - "enabled", - "token_hint" + "components" ] }, - "FeedbackForm": { + "KitPreviewInput": { "type": "object", "properties": { - "event": { - "type": "object", - "additionalProperties": {}, - "readOnly": true - }, - "survey": { + "phase": { "allOf": [ { - "$ref": "#/components/schemas/FeedbackSurvey" + "$ref": "#/components/schemas/PhaseEnum" } ], - "readOnly": true + "default": "issue" }, - "mode": { - "allOf": [ - { - "$ref": "#/components/schemas/FeedbackFormModeEnum" - } - ], - "readOnly": true + "request_id": { + "type": "integer", + "minimum": 1 }, - "requires_auth": { - "type": "boolean", - "readOnly": true + "loan_id": { + "type": "integer", + "minimum": 1 }, - "certificate": { - "allOf": [ - { - "$ref": "#/components/schemas/CertificateSummary" - } - ], - "readOnly": true, + "box_code": { + "type": "string" + }, + "container_id": { + "type": "integer", + "minimum": 1, "nullable": true + }, + "qr_payloads": { + "type": "array", + "items": { + "type": "string", + "maxLength": 64 + }, + "maxItems": 50 + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DirectLoanItem" + } + }, + "rejects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssueReject" + } + }, + "resolutions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReturnItemResolution" + } } - }, - "required": [ - "certificate", - "event", - "mode", - "requires_auth", - "survey" - ] - }, - "FeedbackFormModeEnum": { - "enum": [ - "anonymous", - "certificate" - ], - "type": "string", - "description": "* `anonymous` - anonymous\n* `certificate` - certificate" + } }, - "FeedbackResponse": { + "LabelCommand": { "type": "object", "properties": { "id": { + "type": "string", + "format": "uuid", + "readOnly": true + }, + "command_id": { + "type": "string", + "format": "uuid", + "readOnly": true + }, + "command_status": { + "type": "string", + "readOnly": true + }, + "device_id": { + "type": "string", + "format": "uuid", + "readOnly": true + }, + "batch_id": { "type": "integer", + "readOnly": true, + "nullable": true + }, + "request_id": { + "type": "string", + "format": "uuid", "readOnly": true }, - "answers": { + "batch_id_snapshot": { + "type": "integer", "readOnly": true }, - "created_at": { + "batch_title_snapshot": { "type": "string", - "format": "date-time", "readOnly": true }, - "identity": { - "type": "object", - "additionalProperties": {}, - "nullable": true, + "item_count_snapshot": { + "type": "integer", "readOnly": true }, - "certificate": { + "result": { "allOf": [ { - "$ref": "#/components/schemas/CertificateSummary" + "$ref": "#/components/schemas/ResultEnum" } ], - "nullable": true, "readOnly": true + }, + "result_code": { + "type": "string", + "readOnly": true + }, + "result_message": { + "type": "string", + "readOnly": true + }, + "completed_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "requested_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "batch_id", + "batch_id_snapshot", + "batch_title_snapshot", + "command_id", + "command_status", + "completed_at", + "created_at", + "device_id", + "id", + "item_count_snapshot", + "request_id", + "requested_by_id", + "result", + "result_code", + "result_message" + ] + }, + "LabelCreate": { + "type": "object", + "properties": { + "device_id": { + "type": "string", + "format": "uuid" + }, + "request_id": { + "type": "string", + "format": "uuid" } }, "required": [ - "answers", - "certificate", - "created_at", - "id", - "identity" + "device_id", + "request_id" ] }, - "FeedbackResponseList": { + "LabelReceipt": { "type": "object", "properties": { - "count": { - "type": "integer" + "operation_id": { + "type": "string", + "format": "uuid" }, - "next": { + "label_command_id": { "type": "string", - "format": "uri", - "nullable": true + "format": "uuid" }, - "previous": { + "outcome": { + "$ref": "#/components/schemas/OutcomeA1aEnum" + }, + "result_code": { + "$ref": "#/components/schemas/ResultCodeEnum" + }, + "response_result": { + "$ref": "#/components/schemas/ResponseResultEnum" + }, + "response_command_status": { + "type": "string" + }, + "response_completed_at": { "type": "string", - "format": "uri", + "format": "date-time", "nullable": true - }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FeedbackResponse" - } } }, "required": [ - "count", - "next", - "previous", - "results" + "label_command_id", + "operation_id", + "outcome", + "response_command_status", + "response_completed_at", + "response_result", + "result_code" ] }, - "FeedbackSubmission": { + "LabelResult": { "type": "object", "properties": { - "answers": { - "type": "object", - "additionalProperties": {} - }, - "email": { + "operation_id": { "type": "string", - "format": "email" + "format": "uuid" + }, + "outcome": { + "$ref": "#/components/schemas/OutcomeA1aEnum" + }, + "result_code": { + "$ref": "#/components/schemas/ResultCodeEnum" } - } + }, + "required": [ + "operation_id", + "outcome", + "result_code" + ] }, - "FeedbackSubmissionResponse": { + "LeaseResponse": { "type": "object", "properties": { - "thank_you_text": { - "type": "string" - }, - "certificate": { + "command": { "allOf": [ { - "$ref": "#/components/schemas/CertificateSummary" + "$ref": "#/components/schemas/CommandLease" } ], "nullable": true } }, "required": [ - "certificate", - "thank_you_text" + "command" ] }, - "FeedbackSurvey": { + "LedgerLine": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true + "makerspace_id": { + "type": "integer" }, - "title": { + "currency": { + "type": "string" + }, + "collected_amount": { "type": "string", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" }, - "thank_you_text": { + "refunded_amount": { "type": "string", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" }, - "questions": { - "readOnly": true + "date": { + "type": "string", + "format": "date-time" }, - "is_open": { - "type": "boolean", - "readOnly": true + "row_kind": { + "type": "string" }, - "certificate_enabled": { - "type": "boolean", - "readOnly": true + "row_id": { + "type": "integer" }, - "answered_question_ids": { - "type": "array", - "items": { - "type": "string" - }, - "readOnly": true + "payment_id": { + "type": "integer" }, - "opened_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "document_number": { + "type": "string" }, - "closed_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "subject_type": { + "type": "string" }, - "response_count": { + "payer_type": { + "type": "string" + }, + "payer_id": { "type": "integer", - "readOnly": true - } - }, - "required": [ - "answered_question_ids", - "certificate_enabled", - "closed_at", - "id", - "is_open", - "opened_at", - "questions", - "response_count", - "thank_you_text", - "title" - ] - }, - "FeedbackSurveyAdminEnvelope": { - "type": "object", - "properties": { - "survey": { - "allOf": [ - { - "$ref": "#/components/schemas/FeedbackSurvey" - } - ], "nullable": true + }, + "status": { + "type": "string" + }, + "amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + }, + "owed_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + }, + "method": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "effective": { + "type": "boolean" + }, + "refund_status": { + "type": "string" } }, "required": [ - "survey" + "amount", + "collected_amount", + "currency", + "date", + "document_number", + "effective", + "method", + "owed_amount", + "payer_id", + "payer_type", + "payment_id", + "reference", + "refund_status", + "refunded_amount", + "row_id", + "row_kind", + "status", + "subject_type" ] }, - "FeedbackSurveyWrite": { + "LedgerResponse": { "type": "object", "properties": { - "title": { + "count": { + "type": "integer" + }, + "next": { "type": "string", - "maxLength": 200 + "nullable": true }, - "thank_you_text": { + "previous": { "type": "string", - "default": "", - "maxLength": 2000 + "nullable": true }, - "questions": {}, - "certificate_enabled": { - "type": "boolean", - "default": false + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LedgerRow" + } } }, "required": [ - "questions", - "title" + "count", + "results" ] }, - "FidelityEnum": { - "enum": [ - "REDACTED" - ], - "type": "string", - "description": "* `REDACTED` - Readable — audit metadata and form answers redacted; member contact details included" - }, - "FieldValidationError": { + "LedgerRow": { "type": "object", - "description": "DRF's field-keyed boundary errors; deliberately not the typed service shape.", "properties": { - "non_field_errors": { - "type": "array", - "items": { - "type": "string" - } - }, - "digest": { - "type": "array", - "items": { - "type": "string" - } + "source": { + "$ref": "#/components/schemas/LedgerRowSourceEnum" }, - "decisions": { - "type": "array", - "items": { - "type": "string" - } + "item_name": { + "type": "string" }, - "approval_id": { - "type": "array", - "items": { - "type": "string" - } + "container": { + "type": "string", + "nullable": true }, - "target_age_recipient": { - "type": "array", - "items": { - "type": "string" - } + "holder": { + "type": "string" }, - "archive": { - "type": "array", - "items": { - "type": "string" - } + "quantity": { + "type": "integer" }, - "source_archive_digest": { + "units": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/LedgerUnit" } }, - "target_identity": { - "type": "array", - "items": { - "type": "string" - } + "target_label": { + "type": "string", + "nullable": true }, - "receipt": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "FirstStaffInvite": { - "type": "object", - "properties": { - "email": { + "since": { "type": "string", - "format": "email" + "format": "date-time", + "nullable": true }, - "role_slug": { - "$ref": "#/components/schemas/RoleSlugEnum" - } - }, - "required": [ - "email", - "role_slug" - ] - }, - "ForgotPasswordRequest": { - "type": "object", - "properties": { - "email": { + "due": { "type": "string", - "format": "email" + "format": "date-time", + "nullable": true + }, + "makerspace_id": { + "type": "integer" + }, + "reference_id": { + "type": "integer" + }, + "status": { + "type": "string" } }, "required": [ - "email" + "due", + "holder", + "item_name", + "makerspace_id", + "quantity", + "reference_id", + "since", + "source", + "status", + "units" ] }, - "FrontendDomainStatusEnum": { + "LedgerRowSourceEnum": { "enum": [ - "pending", - "verified", - "failed" + "request", + "self_checkout", + "direct_handout" ], "type": "string", - "description": "* `pending` - Pending\n* `verified` - Verified\n* `failed` - Failed" + "description": "* `request` - request\n* `self_checkout` - self_checkout\n* `direct_handout` - direct_handout" }, - "GenericAnalyticsReport": { + "LedgerUnit": { "type": "object", "properties": { - "report_key": { + "asset_tag": { "type": "string" }, - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "typed_rows": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": {} - } - }, - "meta": { - "type": "object", - "additionalProperties": {} + "serial_number": { + "type": "string" } }, "required": [ - "rows" + "asset_tag", + "serial_number" ] }, - "GenericObject": { + "LegacyResetPasswordConfirm": { "type": "object", "properties": { - "detail": { + "uid": { "type": "string" + }, + "token": { + "type": "string", + "writeOnly": true + }, + "new_password": { + "type": "string", + "writeOnly": true } - } + }, + "required": [ + "new_password", + "token", + "uid" + ] }, - "GithubBrowserStart": { + "LegacyRoleEnum": { + "enum": [ + "space_manager", + "inventory_manager", + "print_manager", + "machine_manager", + "event_manager", + "booking_manager", + "handover" + ], + "type": "string", + "description": "* `space_manager` - Space Manager\n* `inventory_manager` - Inventory Manager\n* `print_manager` - Print Manager\n* `machine_manager` - Machine Manager\n* `event_manager` - Event Manager\n* `booking_manager` - Booking Manager\n* `handover` - Handover" + }, + "LendingHistoryActor": { "type": "object", "properties": { - "redirect_uri": { - "type": "string", - "format": "uri", - "maxLength": 2048 + "username": { + "type": "string" }, - "nonce": { - "type": "string", - "writeOnly": true, - "pattern": "^[A-Za-z0-9_-]{43,128}$" + "role": { + "type": "string" } }, "required": [ - "nonce", - "redirect_uri" + "role", + "username" ] }, - "GithubBrowserStartResponse": { + "LendingHistoryEntry": { "type": "object", "properties": { - "authorization_url": { - "type": "string", - "format": "uri" + "id": { + "type": "integer" }, - "state": { + "username": { "type": "string" }, - "expires_in": { + "issued_at": { + "type": "string", + "format": "date-time" + }, + "quantity": { "type": "integer" + }, + "issued_by": { + "allOf": [ + { + "$ref": "#/components/schemas/LendingHistoryActor" + } + ], + "nullable": true + }, + "accepted_by": { + "allOf": [ + { + "$ref": "#/components/schemas/LendingHistoryActor" + } + ], + "nullable": true } }, "required": [ - "authorization_url", - "expires_in", - "state" + "accepted_by", + "id", + "issued_at", + "issued_by", + "quantity", + "username" ] }, - "HardwareRequestError": { + "LendingHistoryResponse": { "type": "object", "properties": { - "detail": { - "type": "string" + "product_id": { + "type": "integer" }, - "code": { - "type": "string" + "last_borrower": { + "allOf": [ + { + "$ref": "#/components/schemas/LendingHistoryEntry" + } + ], + "nullable": true + }, + "recent": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LendingHistoryEntry" + } } }, "required": [ - "code", - "detail" + "last_borrower", + "product_id", + "recent" ] }, - "Health": { - "type": "object", - "properties": { - "status": { - "type": "string" - } - }, - "required": [ - "status" - ] + "LevelEnum": { + "enum": [ + "info", + "warning", + "critical" + ], + "type": "string", + "description": "* `info` - Info\n* `warning` - Warning\n* `critical` - Critical" }, - "HostWaiver": { + "LinkMachineConsumable": { "type": "object", "properties": { - "id": { + "measurement": { + "$ref": "#/components/schemas/Measurement883Enum" + }, + "product_id": { "type": "integer", - "readOnly": true + "nullable": true }, - "version": { + "label": { "type": "string", - "readOnly": true + "maxLength": 200 }, - "body": { + "remaining": { "type": "string", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "low_threshold": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "nullable": true + }, + "note": { + "type": "string", + "maxLength": 255 } }, "required": [ - "body", - "id", - "version" + "measurement" ] }, - "HostWaiverStateEnum": { + "LoanDepositModeEnum": { "enum": [ - "not_required", - "on_file", - "missing" + "none", + "fixed", + "per_product" ], "type": "string", - "description": "* `not_required` - not_required\n* `on_file` - on_file\n* `missing` - missing" - }, - "HostingError": { - "type": "object", - "properties": { - "detail": { - "type": "string" - } - }, - "required": [ - "detail" - ] + "description": "* `none` - No deposit\n* `fixed` - Fixed amount per loan\n* `per_product` - Sum of product deposits" }, - "IdentityDisclosureDecision": { + "LoanRenewal": { "type": "object", "properties": { - "user_id": { + "id": { "type": "integer" }, - "approved": { - "type": "boolean" + "return_due_at": { + "type": "string", + "format": "date-time" + }, + "renewal_count": { + "type": "integer" } }, "required": [ - "approved", - "user_id" + "id", + "renewal_count", + "return_due_at" ] }, - "IdentityResolutionEnum": { + "LoanRenewalModeEnum": { "enum": [ - "link_existing", - "create_walk_in" + "off", + "auto" ], "type": "string", - "description": "* `link_existing` - Link existing account\n* `create_walk_in` - Create walk-in account" + "description": "* `off` - Off\n* `auto` - Automatic within policy" }, - "ImportCreate": { + "LocationKindEnum": { + "enum": [ + "indoor", + "outdoor", + "other" + ], + "type": "string", + "description": "* `indoor` - Indoor\n* `outdoor` - Outdoor\n* `other` - Other" + }, + "LogError": { "type": "object", "properties": { - "archive": { + "severity": { "type": "string", - "format": "uri", - "writeOnly": true + "maxLength": 16 }, - "source_archive_digest": { - "type": "string", - "pattern": "^[0-9a-f]{64}$" + "message": { + "type": "string" } }, "required": [ - "archive", - "source_archive_digest" + "message", + "severity" ] }, - "ImportDecisionList": { + "LogMachineConsumption": { "type": "object", "properties": { - "decisions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ImportIdentityDecision" - } + "quantity": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" } }, "required": [ - "decisions" + "quantity" ] }, - "ImportIdentityDecision": { + "LogUsage": { "type": "object", "properties": { - "source_user_id": { - "type": "string", - "maxLength": 255 - }, - "identity_resolution": { - "$ref": "#/components/schemas/IdentityResolutionEnum" - }, - "membership_disposition": { - "$ref": "#/components/schemas/MembershipDispositionEnum" - }, - "target_user_id": { + "member": { "type": "integer", + "minimum": 1, "nullable": true - } - }, - "required": [ - "identity_resolution", - "membership_disposition", - "source_user_id" - ] - }, - "ImportJob": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "readOnly": true - }, - "source_archive_digest": { - "type": "string", - "maxLength": 64 - }, - "source_makerspace_id": { - "type": "string", - "maxLength": 64 - }, - "source_makerspace_slug": { - "type": "string", - "maxLength": 100 - }, - "source_makerspace_name": { - "type": "string", - "maxLength": 200 - }, - "source_deployment_id": { - "type": "string", - "maxLength": 128 - }, - "storage_mode": { - "type": "string", - "maxLength": 32 - }, - "status": { - "$ref": "#/components/schemas/ImportJobStatusEnum" - }, - "identity_count": { - "type": "string", - "readOnly": true - }, - "target_lifecycle_state": { - "type": "string", - "nullable": true, - "readOnly": true - }, - "source_deployment_identity": {}, - "aggregate_outcome": {}, - "failure_code": { - "type": "string", - "maxLength": 64 - }, - "failure_detail": { - "type": "string", - "maxLength": 500 - }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "updated_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "expires_at": { - "type": "string", - "format": "date-time" }, - "terminal_at": { + "started_at": { "type": "string", "format": "date-time", "nullable": true }, - "scrubbed_at": { + "hours": { "type": "string", - "format": "date-time", - "nullable": true + "format": "decimal", + "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$" }, - "source_retention_notice": { + "note": { "type": "string", - "readOnly": true + "maxLength": 255 } }, "required": [ - "created_at", - "expires_at", - "id", - "identity_count", - "source_archive_digest", - "source_retention_notice", - "target_lifecycle_state", - "updated_at" + "hours" ] }, - "ImportJobStatusEnum": { - "enum": [ - "pending", - "awaiting_identity", - "ready", - "materializing", - "finalizing", - "completed", - "failed", - "abandoned" - ], - "type": "string", - "description": "* `pending` - Pending\n* `awaiting_identity` - Awaiting identity decisions\n* `ready` - Ready\n* `materializing` - Materializing\n* `finalizing` - Finalizing\n* `completed` - Completed\n* `failed` - Failed\n* `abandoned` - Abandoned" - }, - "ImportRun": { - "type": "object", - "properties": { - "target_identity": { - "type": "object", - "additionalProperties": {} - } - } - }, - "Inbox": { + "LoginRequest": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "feature": { - "type": "string", - "readOnly": true - }, - "event": { - "type": "string", - "readOnly": true - }, - "title": { - "type": "string", - "readOnly": true - }, - "summary": { - "type": "string", - "readOnly": true - }, - "link": { - "type": "string", - "readOnly": true + "username": { + "type": "string" }, - "read_at": { + "password": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "writeOnly": true }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "surface": { + "$ref": "#/components/schemas/Surface817Enum" } }, "required": [ - "created_at", - "event", - "feature", - "id", - "link", - "read_at", - "summary", - "title" + "password", + "username" ] }, - "InboxPage": { + "LoginResponse": { "type": "object", "properties": { - "count": { - "type": "integer" - }, - "next": { - "type": "string", - "format": "uri", - "nullable": true + "user": { + "$ref": "#/components/schemas/AuthUserPayload" }, - "previous": { - "type": "string", - "format": "uri", - "nullable": true + "access": { + "type": "string" }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Inbox" - } + "surface": { + "$ref": "#/components/schemas/LoginResponseSurfaceEnum" } }, "required": [ - "count", - "next", - "previous", - "results" + "access", + "surface", + "user" ] }, - "InheritFieldsEnum": { + "LoginResponseSurfaceEnum": { "enum": [ - "cancellation_cutoff_hours", - "capacity", - "custom_form", - "description", - "ends_at", - "image_key", - "is_public", - "location", - "location_kind", - "payment_amount", - "registration_audience", - "registration_cutoff_at", - "registration_cutoff_lead_minutes", - "registration_requires_approval", - "starts_at", - "title" + "member", + "staff", + "staff_api", + "verification_only" ], "type": "string", - "description": "* `cancellation_cutoff_hours` - cancellation_cutoff_hours\n* `capacity` - capacity\n* `custom_form` - custom_form\n* `description` - description\n* `ends_at` - ends_at\n* `image_key` - image_key\n* `is_public` - is_public\n* `location` - location\n* `location_kind` - location_kind\n* `payment_amount` - payment_amount\n* `registration_audience` - registration_audience\n* `registration_cutoff_at` - registration_cutoff_at\n* `registration_cutoff_lead_minutes` - registration_cutoff_lead_minutes\n* `registration_requires_approval` - registration_requires_approval\n* `starts_at` - starts_at\n* `title` - title" + "description": "* `member` - member\n* `staff` - staff\n* `staff_api` - staff_api\n* `verification_only` - verification_only" }, - "Intake": { + "LogoutAllResponse": { "type": "object", "properties": { - "intake_schema": {} + "web_sessions": { + "type": "integer", + "minimum": 0, + "description": "Revoked browser sessions, including legacy sessions." + }, + "devices": { + "type": "integer", + "minimum": 0 + } }, "required": [ - "intake_schema" + "devices", + "web_sessions" ] }, - "IntegrationConfiguredHealth": { + "LogoutResponse": { "type": "object", "properties": { - "status": { - "$ref": "#/components/schemas/Status83eEnum" - }, "detail": { "type": "string" - }, - "configured": { - "type": "boolean" } - } + }, + "required": [ + "detail" + ] }, - "IntegrationDeliveriesByStream": { + "MFAChallenge": { "type": "object", "properties": { - "status": { - "$ref": "#/components/schemas/Status83eEnum" - }, - "detail": { - "type": "string" - }, - "hardware": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "printing": { + "challenge": { "type": "string", - "format": "date-time", - "nullable": true + "writeOnly": true, + "maxLength": 200, + "minLength": 20 } - } + }, + "required": [ + "challenge" + ] }, - "IntegrationEmailHealth": { + "MFAChallengeResponse": { "type": "object", "properties": { - "status": { - "$ref": "#/components/schemas/Status83eEnum" - }, - "detail": { - "type": "string" - }, - "total": { - "type": "integer" - }, - "pending": { - "type": "integer" + "mfa_required": { + "type": "boolean" }, - "sent": { - "type": "integer" + "enrollment_required": { + "type": "boolean" }, - "failed": { - "type": "integer" + "challenge": { + "type": "string" }, - "stalled": { + "expires_in": { "type": "integer" - }, - "last_failure": { - "allOf": [ - { - "$ref": "#/components/schemas/IntegrationLastFailure" - } - ], - "nullable": true } - } + }, + "required": [ + "challenge", + "enrollment_required", + "expires_in", + "mfa_required" + ] }, - "IntegrationHealth": { + "MFACompleted": { "type": "object", "properties": { - "status": { - "$ref": "#/components/schemas/Status00eEnum" - }, - "email": { - "$ref": "#/components/schemas/IntegrationEmailHealth" - }, - "deliveries_by_stream": { - "$ref": "#/components/schemas/IntegrationDeliveriesByStream" + "user": { + "$ref": "#/components/schemas/AuthUserPayload" }, - "smtp": { - "$ref": "#/components/schemas/IntegrationConfiguredHealth" + "access": { + "type": "string" }, - "telegram": { - "$ref": "#/components/schemas/IntegrationConfiguredHealth" + "surface": { + "type": "string" }, - "worker": { - "$ref": "#/components/schemas/IntegrationWorkerHealth" + "refresh": { + "type": "string" }, - "chat_destinations": { + "device_grant": { "type": "object", "additionalProperties": {} }, - "sms": { - "$ref": "#/components/schemas/IntegrationConfiguredHealth" - }, - "native_push": { - "type": "object", - "additionalProperties": {} + "recovery_codes": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ - "chat_destinations", - "deliveries_by_stream", - "email", - "native_push", - "sms", - "smtp", - "status", - "telegram", - "worker" + "access", + "surface", + "user" ] }, - "IntegrationLastFailure": { + "MFAEnrollment": { "type": "object", "properties": { - "created_at": { - "type": "string", - "format": "date-time" - }, - "subject": { + "secret": { "type": "string" }, - "error": { + "provisioning_uri": { "type": "string" }, - "stream": { + "qr_data_url": { "type": "string" } }, "required": [ - "created_at", - "error", - "stream", - "subject" + "provisioning_uri", + "qr_data_url", + "secret" ] }, - "IntegrationWorkerHealth": { + "MFAEnrollmentStart": { "type": "object", "properties": { - "status": { - "$ref": "#/components/schemas/Status83eEnum" - }, - "detail": { - "type": "string" - }, - "broker_configured": { + "password": { + "type": "string", + "writeOnly": true, + "default": "", + "maxLength": 1024 + } + } + }, + "MFAPassword": { + "type": "object", + "properties": { + "password": { + "type": "string", + "writeOnly": true, + "maxLength": 1024 + } + }, + "required": [ + "password" + ] + }, + "MFARecovery": { + "type": "object", + "properties": { + "recovery_codes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "recovery_codes" + ] + }, + "MFAStatus": { + "type": "object", + "properties": { + "available": { "type": "boolean" }, - "eager": { + "password_available": { "type": "boolean" }, - "last_seen": { - "type": "string", - "format": "date-time", - "nullable": true + "enabled": { + "type": "boolean" }, - "stale": { + "required": { "type": "boolean" } - } - }, - "IntegritySourceEnum": { - "enum": [ - "artifact_ledger", - "archive_row" - ], - "type": "string", - "description": "* `artifact_ledger` - Artifact ledger\n* `archive_row` - Archive row" + }, + "required": [ + "available", + "enabled", + "required" + ] }, - "IntervalEnum": { - "enum": [ - "monthly", - "yearly", - "custom_days" - ], - "type": "string", - "description": "* `monthly` - Monthly\n* `yearly` - Yearly\n* `custom_days` - Custom number of days" + "MFAVerify": { + "type": "object", + "properties": { + "challenge": { + "type": "string", + "writeOnly": true, + "maxLength": 200, + "minLength": 20 + }, + "code": { + "type": "string", + "writeOnly": true, + "maxLength": 64, + "minLength": 6 + } + }, + "required": [ + "challenge", + "code" + ] }, - "InventoryAssetAdmin": { + "Machine": { "type": "object", "properties": { "id": { @@ -77992,56 +100122,110 @@ "type": "integer", "readOnly": true }, - "product": { - "type": "integer", - "readOnly": true - }, - "product_name": { - "type": "string", + "machine_type": { + "allOf": [ + { + "$ref": "#/components/schemas/MachineType" + } + ], "readOnly": true }, - "box": { + "machine_type_id": { "type": "integer", - "readOnly": true, - "nullable": true + "writeOnly": true }, - "box_label": { + "name": { "type": "string", - "readOnly": true, - "nullable": true + "maxLength": 200 }, - "asset_tag": { + "location": { "type": "string", - "readOnly": true + "maxLength": 200 }, - "serial_number": { - "type": "string", - "readOnly": true + "notes": { + "type": "string" }, "status": { "allOf": [ { - "$ref": "#/components/schemas/InventoryAssetStatusEnum" + "$ref": "#/components/schemas/MachineStatusEnum" } ], "readOnly": true }, - "qr_code_id": { - "type": "integer", - "nullable": true, + "firmware_version": { + "type": "string", + "maxLength": 100 + }, + "camera_feed_url": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "maxLength": 200 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "status_since": { + "type": "string", + "format": "date-time", "readOnly": true }, - "qr_payload": { + "public_fields": {}, + "type_payload": {}, + "image_url": { "type": "string", "nullable": true, "readOnly": true }, - "public_self_checkout_enabled": { + "warranty_status": { + "type": "string", + "readOnly": true + }, + "is_public": { "type": "boolean", "readOnly": true }, - "notes": { + "is_active": { + "type": "boolean", + "readOnly": true + }, + "usage_hours": { + "type": "number", + "format": "double", + "readOnly": true + }, + "can_operate": { + "type": "boolean", + "readOnly": true + }, + "can_edit": { + "type": "boolean", + "readOnly": true + }, + "can_delegate": { + "type": "boolean", + "readOnly": true + }, + "can_retire": { + "type": "boolean", + "readOnly": true + }, + "can_unretire": { + "type": "boolean", + "readOnly": true + }, + "can_manage": { + "type": "boolean", + "readOnly": true + }, + "created_at": { "type": "string", + "format": "date-time", "readOnly": true }, "updated_at": { @@ -78051,818 +100235,818 @@ } }, "required": [ - "asset_tag", - "box", - "box_label", + "can_delegate", + "can_edit", + "can_manage", + "can_operate", + "can_retire", + "can_unretire", + "created_at", "id", + "image_url", + "is_active", + "is_public", + "machine_type", + "machine_type_id", "makerspace", - "notes", - "product", - "product_name", - "public_self_checkout_enabled", - "qr_code_id", - "qr_payload", - "serial_number", + "name", "status", - "updated_at" - ] - }, - "InventoryAssetStatusAction": { - "type": "object", - "properties": { - "action": { - "$ref": "#/components/schemas/InventoryAssetStatusActionActionEnum" - } - }, - "required": [ - "action" + "status_since", + "updated_at", + "usage_hours", + "warranty_status" ] }, - "InventoryAssetStatusActionActionEnum": { - "enum": [ - "shelve", - "repair" - ], - "type": "string", - "description": "* `shelve` - shelve\n* `repair` - repair" - }, - "InventoryAssetStatusEnum": { - "enum": [ - "available", - "reserved", - "issued", - "damaged", - "lost", - "retired", - "maintenance" - ], - "type": "string", - "description": "* `available` - Available\n* `reserved` - Reserved\n* `issued` - Issued\n* `damaged` - Damaged\n* `lost` - Lost\n* `retired` - Retired\n* `maintenance` - Maintenance" - }, - "InventoryChainOfCustodyResponse": { + "MachineConsumable": { "type": "object", "properties": { - "product_id": { - "type": "integer" - }, - "product_name": { - "type": "string" - }, - "tracking_mode": { - "type": "string" - }, - "limit": { - "type": "integer" - }, - "truncated": { - "type": "boolean" - }, - "events": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TimelineEvent" - } - }, - "asset_groups": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AssetChainGroup" - } + "id": { + "type": "integer", + "readOnly": true }, - "quantity_summary": { + "measurement": { "allOf": [ { - "$ref": "#/components/schemas/QuantityChainSummary" + "$ref": "#/components/schemas/Measurement883Enum" } ], - "nullable": true - } - }, - "required": [ - "asset_groups", - "events", - "limit", - "product_id", - "product_name", - "quantity_summary", - "tracking_mode", - "truncated" - ] - }, - "InventoryProductAdmin": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "makerspace": { - "type": "integer", "readOnly": true }, - "box": { - "type": "integer", - "nullable": true - }, - "category": { + "product": { "type": "integer", + "readOnly": true, "nullable": true }, - "name": { + "product_name": { "type": "string", - "maxLength": 200 + "nullable": true, + "readOnly": true }, - "manufacturer": { - "type": "string", - "maxLength": 200 + "available": { + "type": "integer", + "nullable": true, + "readOnly": true }, - "model": { + "remaining": { "type": "string", - "maxLength": 200 + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "nullable": true, + "readOnly": true }, - "sku": { + "label": { "type": "string", - "maxLength": 100 + "readOnly": true }, - "barcode": { + "low_threshold": { "type": "string", - "maxLength": 100 + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true, + "nullable": true }, - "supplier": { + "note": { "type": "string", - "maxLength": 200 - }, - "reorder_point": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "readOnly": true }, - "reorder_quantity": { + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "available", + "created_at", + "id", + "label", + "low_threshold", + "measurement", + "note", + "product", + "product_name", + "remaining" + ] + }, + "MachineDocument": { + "type": "object", + "properties": { + "id": { "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "kit_components": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KitComponent" - }, - "maxItems": 50 - }, - "description": { - "type": "string" + "readOnly": true }, - "borrowing_terms": { - "type": "string", - "maxLength": 2000 + "doc_type": { + "allOf": [ + { + "$ref": "#/components/schemas/DocTypeEnum" + } + ], + "readOnly": true }, - "image_key": { + "original_filename": { "type": "string", "readOnly": true }, - "image_url": { + "content_type": { "type": "string", - "format": "uri", - "nullable": true, "readOnly": true }, - "tracking_mode": { - "$ref": "#/components/schemas/TrackingModeB86Enum" - }, - "loan_days": { - "type": "integer", - "maximum": 365, - "minimum": 1, - "nullable": true - }, - "max_renewals": { - "type": "integer", - "maximum": 100, - "minimum": 0, - "nullable": true - }, - "total_quantity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "available_quantity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "reserved_quantity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "issued_quantity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "damaged_quantity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "lost_quantity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "needs_fix_quantity": { + "size_bytes": { "type": "integer", "readOnly": true }, - "is_public": { - "type": "boolean" - }, - "public_self_checkout_enabled": { - "type": "boolean" - }, - "show_public_count": { - "type": "boolean" - }, - "public_availability_mode": { - "$ref": "#/components/schemas/PublicAvailabilityModeFc2Enum" - }, - "storage_location": { - "type": "string", - "maxLength": 200 - }, - "is_archived": { - "type": "boolean" - }, "created_at": { "type": "string", "format": "date-time", "readOnly": true - }, - "updated_at": { - "type": "string", - "format": "date-time", - "readOnly": true } }, "required": [ + "content_type", "created_at", + "doc_type", "id", - "image_key", - "image_url", - "makerspace", - "name", - "needs_fix_quantity", - "updated_at" + "original_filename", + "size_bytes" ] }, - "InventoryProductAdminCreate": { + "MachineDowntimeReport": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "makerspace": { - "type": "integer", - "readOnly": true + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineDowntimeRow" + } + } + }, + "required": [ + "rows", + "typed_rows" + ] + }, + "MachineDowntimeRow": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer" }, - "box": { - "type": "integer", + "period": { + "type": "string", + "format": "date", "nullable": true }, - "category": { + "machine_id": { "type": "integer", "nullable": true }, - "name": { - "type": "string", - "maxLength": 200 - }, - "manufacturer": { - "type": "string", - "maxLength": 200 - }, - "model": { - "type": "string", - "maxLength": 200 - }, - "sku": { - "type": "string", - "maxLength": 100 - }, - "barcode": { - "type": "string", - "maxLength": 100 - }, - "supplier": { + "machine_name": { "type": "string", - "maxLength": 200 - }, - "reorder_point": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "reorder_quantity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "kit_components": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KitComponent" - }, - "maxItems": 50 - }, - "description": { - "type": "string" + "nullable": true }, - "borrowing_terms": { + "machine_type": { "type": "string", - "maxLength": 2000 + "nullable": true }, - "image_key": { + "status": { "type": "string", - "readOnly": true + "description": "Unknown before the first recorded status event." }, - "image_url": { + "status_hours": { "type": "string", - "format": "uri", - "nullable": true, - "readOnly": true - }, - "tracking_mode": { - "$ref": "#/components/schemas/TrackingModeB86Enum" - }, - "loan_days": { - "type": "integer", - "maximum": 365, - "minimum": 1, - "nullable": true - }, - "max_renewals": { - "type": "integer", - "maximum": 100, - "minimum": 0, + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$", "nullable": true - }, - "total_quantity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "available_quantity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "reserved_quantity": { - "type": "integer", - "readOnly": true - }, - "issued_quantity": { - "type": "integer", - "readOnly": true - }, - "damaged_quantity": { + } + }, + "required": [ + "machine_id", + "machine_name", + "machine_type", + "period", + "status", + "status_hours" + ] + }, + "MachineErrorLog": { + "type": "object", + "properties": { + "id": { "type": "integer", "readOnly": true }, - "lost_quantity": { - "type": "integer", + "severity": { + "allOf": [ + { + "$ref": "#/components/schemas/MachineErrorLogSeverityEnum" + } + ], "readOnly": true }, - "needs_fix_quantity": { - "type": "integer", + "message": { + "type": "string", "readOnly": true }, - "is_public": { - "type": "boolean" - }, - "public_self_checkout_enabled": { - "type": "boolean" - }, - "show_public_count": { - "type": "boolean" - }, - "public_availability_mode": { - "$ref": "#/components/schemas/PublicAvailabilityModeFc2Enum" - }, - "storage_location": { + "logged_by_username": { "type": "string", - "maxLength": 200 - }, - "is_archived": { - "type": "boolean" + "readOnly": true, + "nullable": true }, "created_at": { "type": "string", "format": "date-time", "readOnly": true - }, - "updated_at": { - "type": "string", - "format": "date-time", - "readOnly": true } }, "required": [ "created_at", - "damaged_quantity", "id", - "image_key", - "image_url", - "issued_quantity", - "lost_quantity", - "makerspace", - "name", - "needs_fix_quantity", - "reserved_quantity", - "updated_at" + "logged_by_username", + "message", + "severity" ] }, - "InventoryProductAdminUpdate": { + "MachineErrorLogSeverityEnum": { + "enum": [ + "info", + "warning", + "error", + "critical" + ], + "type": "string", + "description": "* `info` - Info\n* `warning` - Warning\n* `error` - Error\n* `critical` - Critical" + }, + "MachineErrorsReport": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "makerspace": { - "type": "integer", - "readOnly": true + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineErrorsRow" + } + } + }, + "required": [ + "rows", + "typed_rows" + ] + }, + "MachineErrorsRow": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer" }, - "box": { - "type": "integer", + "period": { + "type": "string", + "format": "date", "nullable": true }, - "category": { + "machine_id": { "type": "integer", "nullable": true }, - "name": { + "machine_name": { "type": "string", - "maxLength": 200 + "nullable": true }, - "manufacturer": { + "machine_type": { "type": "string", - "maxLength": 200 + "nullable": true }, - "model": { - "type": "string", - "maxLength": 200 + "severity": { + "$ref": "#/components/schemas/MachineErrorsRowSeverityEnum" }, - "sku": { - "type": "string", - "maxLength": 100 + "error_count": { + "type": "integer" + } + }, + "required": [ + "error_count", + "machine_id", + "machine_name", + "machine_type", + "period", + "severity" + ] + }, + "MachineErrorsRowSeverityEnum": { + "enum": [ + "info", + "warning", + "error", + "critical" + ], + "type": "string", + "description": "* `info` - info\n* `warning` - warning\n* `error` - error\n* `critical` - critical" + }, + "MachineHeatmapRow": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer" }, - "barcode": { + "period": { "type": "string", - "maxLength": 100 + "format": "date" }, - "supplier": { - "type": "string", - "maxLength": 200 + "measure": { + "$ref": "#/components/schemas/MeasureEnum" }, - "reorder_point": { + "machine_type_id": { "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "nullable": true }, - "reorder_quantity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "machine_type": { + "type": "string" }, - "kit_components": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KitComponent" - }, - "maxItems": 50 + "weekday": { + "type": "integer", + "maximum": 6, + "minimum": 0, + "description": "Monday is zero." }, - "description": { - "type": "string" + "hour": { + "type": "integer", + "maximum": 23, + "minimum": 0 }, - "borrowing_terms": { + "service_hours": { "type": "string", - "maxLength": 2000 + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$", + "nullable": true }, - "image_key": { + "manual_hours": { "type": "string", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$", + "nullable": true }, - "image_url": { + "booked_hours": { "type": "string", - "format": "uri", - "nullable": true, - "readOnly": true - }, - "tracking_mode": { - "$ref": "#/components/schemas/TrackingModeB86Enum" - }, - "loan_days": { - "type": "integer", - "maximum": 365, - "minimum": 1, + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$", "nullable": true + } + }, + "required": [ + "booked_hours", + "hour", + "machine_type", + "machine_type_id", + "manual_hours", + "measure", + "period", + "service_hours", + "weekday" + ] + }, + "MachineListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" }, - "max_renewals": { - "type": "integer", - "maximum": 100, - "minimum": 0, - "nullable": true + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Machine" + } + } + }, + "required": [ + "count", + "results" + ] + }, + "MachineMemberRow": { + "type": "object", + "properties": { + "period": { + "type": "string", + "format": "date" }, - "total_quantity": { + "member_id": { "type": "integer", - "readOnly": true + "nullable": true, + "description": "Null means unattributed, never inferred from contacts." }, - "available_quantity": { - "type": "integer", - "readOnly": true + "measure": { + "$ref": "#/components/schemas/MeasureEnum" }, - "reserved_quantity": { + "machine_type_id": { "type": "integer", - "readOnly": true + "nullable": true }, - "issued_quantity": { - "type": "integer", - "readOnly": true + "machine_type": { + "type": "string" }, - "damaged_quantity": { - "type": "integer", - "readOnly": true + "entries": { + "type": "integer" }, - "lost_quantity": { + "hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$", + "nullable": true + } + }, + "required": [ + "entries", + "hours", + "machine_type", + "machine_type_id", + "measure", + "member_id", + "period" + ] + }, + "MachineOperator": { + "type": "object", + "properties": { + "id": { "type": "integer", "readOnly": true }, - "needs_fix_quantity": { + "user": { "type": "integer", "readOnly": true }, - "is_public": { - "type": "boolean" - }, - "public_self_checkout_enabled": { - "type": "boolean" - }, - "show_public_count": { - "type": "boolean" - }, - "public_availability_mode": { - "$ref": "#/components/schemas/PublicAvailabilityModeFc2Enum" - }, - "storage_location": { + "username": { "type": "string", - "maxLength": 200 + "readOnly": true }, - "is_archived": { - "type": "boolean" + "access_level": { + "allOf": [ + { + "$ref": "#/components/schemas/AccessLevelEnum" + } + ], + "readOnly": true }, - "created_at": { + "assigned_by_username": { "type": "string", - "format": "date-time", - "readOnly": true + "readOnly": true, + "nullable": true }, - "updated_at": { + "assigned_at": { "type": "string", "format": "date-time", "readOnly": true } }, "required": [ - "available_quantity", - "created_at", - "damaged_quantity", + "access_level", + "assigned_at", + "assigned_by_username", "id", - "image_key", - "image_url", - "issued_quantity", - "lost_quantity", - "makerspace", - "name", - "needs_fix_quantity", - "reserved_quantity", - "total_quantity", - "updated_at" + "user", + "username" ] }, - "InventoryQuantityAdjustment": { + "MachineServiceConsumption": { "type": "object", "properties": { - "delta_available": { - "type": "integer", - "default": 0 + "makerspace_id": { + "type": "integer" }, - "delta_damaged": { - "type": "integer", - "default": 0 + "machine_id": { + "type": "integer" }, - "delta_lost": { + "machine_name": { + "type": "string" + }, + "machine_type": { + "type": "string", + "nullable": true + }, + "measurement": { + "$ref": "#/components/schemas/MachineServiceConsumptionMeasurementEnum" + }, + "product_id": { "type": "integer", - "default": 0 + "nullable": true }, - "reason": { + "product_label": { "type": "string" + }, + "completed_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "failed_partial_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "total_used": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" } }, "required": [ - "reason" + "completed_amount", + "failed_partial_amount", + "machine_id", + "machine_name", + "machine_type", + "measurement", + "product_id", + "product_label", + "total_used" ] }, - "Invitation": { + "MachineServiceConsumptionMeasurementEnum": { + "enum": [ + "count", + "grams" + ], + "type": "string", + "description": "* `count` - count\n* `grams` - grams" + }, + "MachineServiceFailure": { "type": "object", "properties": { - "role_id": { + "makerspace_id": { "type": "integer" }, - "invite_email": { + "machine_id": { + "type": "integer" + }, + "machine_name": { + "type": "string" + }, + "machine_type": { "type": "string", - "format": "email", - "maxLength": 254 + "nullable": true + }, + "outcome": { + "type": "string" + }, + "failed_count": { + "type": "integer" + }, + "failed_partial_hours": { + "type": "number", + "format": "double" + }, + "failed_count_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "failed_grams_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" } }, "required": [ - "invite_email", - "role_id" + "failed_count", + "failed_count_amount", + "failed_grams_amount", + "failed_partial_hours", + "machine_id", + "machine_name", + "machine_type", + "outcome" ] }, - "InvitationClaimOutcome": { + "MachineServiceMachine": { "type": "object", "properties": { - "id": { + "makerspace_id": { "type": "integer" }, - "outcome": { - "$ref": "#/components/schemas/InvitationClaimOutcomeOutcomeEnum" + "machine_id": { + "type": "integer" + }, + "machine_name": { + "type": "string" + }, + "machine_type": { + "type": "string", + "nullable": true + }, + "request_count": { + "type": "integer" + }, + "completed_count": { + "type": "integer" + }, + "failed_count": { + "type": "integer" + }, + "completed_hours": { + "type": "number", + "format": "double" + }, + "failed_partial_hours": { + "type": "number", + "format": "double" + }, + "total_recorded_service_hours": { + "type": "number", + "format": "double" + }, + "failure_rate": { + "type": "number", + "format": "double", + "nullable": true } }, "required": [ - "id", - "outcome" + "completed_count", + "completed_hours", + "failed_count", + "failed_partial_hours", + "failure_rate", + "machine_id", + "machine_name", + "machine_type", + "request_count", + "total_recorded_service_hours" ] }, - "InvitationClaimOutcomeOutcomeEnum": { - "enum": [ - "active", - "pending_approval", - "awaiting_vouches", - "awaiting_payment" - ], - "type": "string", - "description": "* `active` - active\n* `pending_approval` - pending_approval\n* `awaiting_vouches` - awaiting_vouches\n* `awaiting_payment` - awaiting_payment" - }, - "InvitationList": { + "MachineServiceReport": { "type": "object", "properties": { - "invitations": { + "status_totals": { "type": "array", "items": { - "$ref": "#/components/schemas/ClaimableInvitation" + "$ref": "#/components/schemas/MachineServiceStatusTotals" + } + }, + "machines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineServiceMachine" + } + }, + "consumption": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineServiceConsumption" + } + }, + "failure_summary": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineServiceFailure" } } }, "required": [ - "invitations" + "consumption", + "failure_summary", + "machines", + "status_totals" ] }, - "InvitationRequest": { + "MachineServiceReportResponse": { + "oneOf": [ + { + "$ref": "#/components/schemas/MachineServiceReport" + }, + { + "$ref": "#/components/schemas/PrinterServiceReport" + } + ] + }, + "MachineServiceRequest": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "name": { - "type": "string", + "bucket_id": { + "type": "integer", "readOnly": true }, - "email": { - "type": "string", + "queue_id": { + "type": "integer", "readOnly": true }, - "phone": { + "machine_type": { "type": "string", "readOnly": true }, - "message": { - "type": "string", + "machine": { + "allOf": [ + { + "$ref": "#/components/schemas/ServiceMachine" + } + ], "readOnly": true }, - "status": { + "assigned_machine": { "allOf": [ { - "$ref": "#/components/schemas/InvitationRequestStatusEnum" + "$ref": "#/components/schemas/ServiceMachine" } ], "readOnly": true }, - "handled_by": { - "type": "integer", - "readOnly": true, - "nullable": true + "requester": { + "allOf": [ + { + "$ref": "#/components/schemas/ServiceRequester" + } + ], + "readOnly": true }, - "handled_at": { + "requester_name": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "readOnly": true }, - "created_at": { + "contact_email": { "type": "string", - "format": "date-time", "readOnly": true - } - }, - "required": [ - "created_at", - "email", - "handled_at", - "handled_by", - "id", - "message", - "name", - "phone", - "status" - ] - }, - "InvitationRequestAck": { - "type": "object", - "properties": { - "detail": { - "type": "string" - } - }, - "required": [ - "detail" - ] - }, - "InvitationRequestCreate": { - "type": "object", - "properties": { - "name": { + }, + "contact_phone": { "type": "string", - "maxLength": 200 + "readOnly": true }, - "email": { + "title": { "type": "string", - "format": "email", - "maxLength": 254 + "readOnly": true }, - "phone": { + "description": { "type": "string", - "default": "", - "maxLength": 32 + "readOnly": true }, - "message": { + "source_link": { "type": "string", - "default": "", - "maxLength": 1000 + "format": "uri", + "readOnly": true }, - "website": { + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/MachineServiceRequestStatusEnum" + } + ], + "readOnly": true + }, + "reason": { "type": "string", - "writeOnly": true - } - }, - "required": [ - "email", - "name" - ] - }, - "InvitationRequestStatusEnum": { - "enum": [ - "pending", - "invited", - "declined" - ], - "type": "string", - "description": "* `pending` - Pending\n* `invited` - Invited\n* `declined` - Declined" - }, - "Invite": { - "type": "object", - "properties": { - "id": { + "readOnly": true + }, + "estimated_minutes": { "type": "integer", "readOnly": true }, - "max_uses": { + "actual_minutes": { "type": "integer", "readOnly": true }, - "uses": { + "fail_percent_complete": { "type": "integer", "readOnly": true }, - "expires_at": { + "accepted_at": { "type": "string", "format": "date-time", "readOnly": true, "nullable": true }, - "revoked_at": { + "started_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "completed_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "failed_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "collected_at": { "type": "string", "format": "date-time", "readOnly": true, @@ -78873,1150 +101057,1176 @@ "format": "date-time", "readOnly": true }, - "code": { + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "capability_payload": { + "readOnly": true + }, + "metering_unit": { "type": "string", "readOnly": true, - "description": "Returned once on creation only." + "nullable": true + }, + "planned_quantity": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true, + "nullable": true + }, + "reserved_quantity": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true, + "nullable": true + }, + "actual_consumed_quantity": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true, + "nullable": true + }, + "planned_grams": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true + }, + "reserved_grams": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true + }, + "actual_consumed_grams": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true + }, + "payment": { + "allOf": [ + { + "$ref": "#/components/schemas/StaffPayment" + } + ], + "nullable": true, + "readOnly": true + }, + "run_machine_model": { + "type": "string", + "readOnly": true + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ServiceFile" + }, + "readOnly": true + }, + "consumptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ServiceConsumption" + }, + "readOnly": true } }, "required": [ - "code", + "accepted_at", + "actual_consumed_grams", + "actual_consumed_quantity", + "actual_minutes", + "assigned_machine", + "bucket_id", + "capability_payload", + "collected_at", + "completed_at", + "consumptions", + "contact_email", + "contact_phone", "created_at", - "expires_at", + "description", + "estimated_minutes", + "fail_percent_complete", + "failed_at", + "files", "id", - "max_uses", - "revoked_at", - "uses" + "machine", + "machine_type", + "metering_unit", + "payment", + "planned_grams", + "planned_quantity", + "queue_id", + "reason", + "requester", + "requester_name", + "reserved_grams", + "reserved_quantity", + "run_machine_model", + "source_link", + "started_at", + "status", + "title", + "updated_at" ] }, - "InviteInput": { + "MachineServiceRequestStatusEnum": { + "enum": [ + "pending", + "accepted", + "in_progress", + "completed", + "collected", + "rejected", + "failed" + ], + "type": "string", + "description": "* `pending` - Pending\n* `accepted` - Accepted\n* `in_progress` - In progress\n* `completed` - Completed\n* `collected` - Collected\n* `rejected` - Rejected\n* `failed` - Failed" + }, + "MachineServiceStatusTotals": { "type": "object", "properties": { - "email": { - "writeOnly": true, - "oneOf": [ - { - "type": "string", - "format": "email" - }, - { - "type": "string", - "maxLength": 0 - } - ] + "makerspace_id": { + "type": "integer" }, - "max_uses": { - "type": "integer", - "minimum": 1, - "default": 1 + "submitted": { + "type": "integer" }, - "expires_at": { - "type": "string", - "format": "date-time", - "nullable": true - } - } - }, - "InviteList": { - "type": "object", - "properties": { - "count": { + "accepted": { "type": "integer" }, - "next": { - "type": "string", - "format": "uri", - "nullable": true + "in_progress": { + "type": "integer" }, - "previous": { - "type": "string", - "format": "uri", - "nullable": true + "completed": { + "type": "integer" }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Invite" - } + "collected": { + "type": "integer" + }, + "rejected": { + "type": "integer" + }, + "failed": { + "type": "integer" } }, "required": [ - "count", - "next", - "previous", - "results" + "accepted", + "collected", + "completed", + "failed", + "in_progress", + "rejected", + "submitted" ] }, - "IssueReject": { + "MachineServiceSubmit": { "type": "object", "properties": { - "item_id": { - "type": "integer" + "requester_id": { + "type": "integer", + "minimum": 1 }, - "broken": { + "machine_id": { "type": "integer", - "minimum": 0, - "default": 0 + "minimum": 1 }, - "disposition": { - "allOf": [ + "title": { + "type": "string", + "maxLength": 200 + }, + "description": { + "type": "string" + }, + "source_link": { + "oneOf": [ { - "$ref": "#/components/schemas/DispositionEnum" + "type": "string", + "format": "uri" + }, + { + "type": "string", + "maxLength": 0 } - ], - "default": "needs_fix" - } + ] + }, + "requester_name": { + "type": "string" + }, + "contact_email": { + "oneOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "contact_phone": { + "type": "string", + "maxLength": 32 + }, + "capability_payload": {} }, "required": [ - "item_id" + "machine_id", + "requester_id", + "title" ] }, - "IssueRequest": { + "MachineServiceV2Report": { "type": "object", "properties": { - "kit_checklist": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KitCount" - }, - "maxItems": 3550 - }, - "evidence_id": { - "type": "integer" - }, - "remark": { - "type": "string", - "default": "" - }, - "asset_qr_payloads": { + "rows": { "type": "array", "items": { - "type": "string" + "type": "array", + "items": {} } }, - "rejects": { + "typed_rows": { "type": "array", "items": { - "$ref": "#/components/schemas/IssueReject" + "$ref": "#/components/schemas/MachineServiceV2Row" } } }, "required": [ - "evidence_id" + "rows", + "typed_rows" ] }, - "Job": { + "MachineServiceV2Row": { "type": "object", "properties": { - "id": { - "type": "string", - "format": "uuid", - "readOnly": true + "makerspace_id": { + "type": "integer" }, - "command": { + "period": { "type": "string", - "format": "uuid", - "readOnly": true + "format": "date", + "nullable": true }, - "makerspace": { + "machine_id": { "type": "integer", - "readOnly": true + "nullable": true }, - "device": { + "machine_name": { "type": "string", - "format": "uuid", - "readOnly": true + "nullable": true }, - "machine": { + "machine_type": { + "type": "string", + "nullable": true + }, + "row_kind": { + "$ref": "#/components/schemas/RowKindEnum" + }, + "submitted": { "type": "integer", - "readOnly": true, "nullable": true }, - "service_request": { + "accepted": { "type": "integer", - "readOnly": true, "nullable": true }, - "machine_name": { - "type": "string", - "readOnly": true + "in_progress": { + "type": "integer", + "nullable": true }, - "service_request_name": { - "type": "string", - "readOnly": true + "completed": { + "type": "integer", + "nullable": true }, - "external_job_id": { - "type": "string", - "readOnly": true + "collected": { + "type": "integer", + "nullable": true }, - "state": { - "allOf": [ - { - "$ref": "#/components/schemas/JobStateEnum" - } - ], - "readOnly": true, - "default": "awaiting" + "rejected": { + "type": "integer", + "nullable": true }, - "telemetry_state": { - "type": "string", - "readOnly": true + "failed": { + "type": "integer", + "nullable": true }, - "progress_percent": { + "request_count": { "type": "integer", - "readOnly": true, "nullable": true }, - "telemetry_at": { - "type": "string", - "format": "date-time", - "readOnly": true, + "completed_count": { + "type": "integer", "nullable": true }, - "terminal_operation_id": { + "failed_count": { + "type": "integer", + "nullable": true + }, + "completed_hours": { "type": "string", - "format": "uuid", - "readOnly": true, + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$", "nullable": true }, - "outcome": { - "allOf": [ - { - "$ref": "#/components/schemas/JobOutcomeEnum" - } - ], - "readOnly": true + "failed_partial_hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$", + "nullable": true }, - "actual_minutes": { - "type": "integer", - "readOnly": true, + "total_recorded_service_hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$", "nullable": true }, - "failure_reason": { + "failure_rate": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Failed / (completed + failed) × 100 at terminal time." + }, + "measurement": { "type": "string", - "readOnly": true + "nullable": true }, - "percent_complete": { + "product_id": { "type": "integer", - "readOnly": true, "nullable": true }, - "actual_grams": { + "product_label": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "readOnly": true, "nullable": true }, - "actual_quantity": { + "completed_amount": { "type": "string", "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "readOnly": true, + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$", "nullable": true }, - "reported_at": { + "failed_partial_amount": { "type": "string", - "format": "date-time", - "readOnly": true, + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$", "nullable": true }, - "applied_at": { + "total_used": { "type": "string", - "format": "date-time", - "readOnly": true, + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$", "nullable": true }, - "processed_by": { - "type": "integer", - "readOnly": true, + "outcome": { + "type": "string", "nullable": true }, - "created_at": { + "failed_count_amount": { "type": "string", - "format": "date-time", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$", + "nullable": true }, - "consumptions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/JobConsumption" - }, - "readOnly": true + "failed_grams_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$", + "nullable": true } }, "required": [ - "actual_grams", - "actual_minutes", - "actual_quantity", - "applied_at", - "command", - "consumptions", - "created_at", - "device", - "external_job_id", - "failure_reason", - "id", - "machine", + "accepted", + "collected", + "completed", + "completed_amount", + "completed_count", + "completed_hours", + "failed", + "failed_count", + "failed_count_amount", + "failed_grams_amount", + "failed_partial_amount", + "failed_partial_hours", + "failure_rate", + "in_progress", + "machine_id", "machine_name", - "makerspace", + "machine_type", + "measurement", "outcome", - "percent_complete", - "processed_by", - "progress_percent", - "reported_at", - "service_request", - "service_request_name", - "state", - "telemetry_at", - "telemetry_state", - "terminal_operation_id" + "period", + "product_id", + "product_label", + "rejected", + "request_count", + "row_kind", + "submitted", + "total_recorded_service_hours", + "total_used" ] }, - "JobConsumption": { + "MachineStatusEnum": { + "enum": [ + "idle", + "running", + "reserved", + "maintenance", + "offline" + ], + "type": "string", + "description": "* `idle` - Idle\n* `running` - Running\n* `reserved` - Reserved\n* `maintenance` - Maintenance\n* `offline` - Offline" + }, + "MachineType": { "type": "object", "properties": { - "machine_consumable": { + "id": { "type": "integer", - "nullable": true + "readOnly": true }, - "machine_consumable_id_snapshot": { - "type": "integer", - "maximum": 9223372036854775807, - "minimum": 0, - "format": "int64" + "slug": { + "type": "string", + "maxLength": 50, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "quantity": { + "name": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - } - }, - "required": [ - "machine_consumable_id_snapshot", - "quantity" - ] - }, - "JobCreate": { - "type": "object", - "properties": { - "binding_id": { + "maxLength": 200 + }, + "icon": { "type": "string", - "format": "uuid" + "maxLength": 50 }, - "service_request_id": { - "type": "integer", - "minimum": 1 + "is_builtin": { + "type": "boolean" }, - "external_job_id": { + "managing_action": { "type": "string", - "maxLength": 120 + "readOnly": true + }, + "capability_config": { + "readOnly": true + }, + "makerspace": { + "type": "integer", + "readOnly": true, + "nullable": true } }, "required": [ - "binding_id", - "external_job_id", - "service_request_id" + "capability_config", + "id", + "makerspace", + "managing_action", + "name", + "slug" ] }, - "JobOutcomeEnum": { - "enum": [ - "completed", - "failed" - ], - "type": "string", - "description": "* `completed` - Completed\n* `failed` - Failed" - }, - "JobStateEnum": { - "enum": [ - "awaiting", - "reported", - "applied", - "needs_review", - "cancelled" - ], - "type": "string", - "description": "* `awaiting` - Awaiting\n* `reported` - Reported\n* `applied` - Applied\n* `needs_review` - Needs review\n* `cancelled` - Cancelled" - }, - "JoinModeEnum": { - "enum": [ - "open", - "approval", - "invite_only", - "vouch", - "closed" - ], - "type": "string", - "description": "* `open` - Open\n* `approval` - Approval\n* `invite_only` - Invite only\n* `vouch` - Vouch\n* `closed` - Closed" - }, - "JournalLine": { + "MachineTypeAccess": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" + "id": { + "type": "integer", + "readOnly": true }, - "date": { + "slug": { "type": "string", - "format": "date" - }, - "document_number": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "payment_id": { - "type": "integer" + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "subject_type": { - "type": "string" + "name": { + "type": "string", + "readOnly": true }, - "currency": { - "type": "string" + "icon": { + "type": "string", + "readOnly": true }, - "account_code": { - "type": "string" + "is_builtin": { + "type": "boolean", + "readOnly": true }, - "debit": { + "managing_action": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + "readOnly": true }, - "credit": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + "capability_config": { + "readOnly": true }, - "settlement_method": { - "type": "string" - } - }, - "required": [ - "account_code", - "credit", - "currency", - "date", - "debit", - "document_number", - "kind", - "payment_id", - "settlement_method", - "subject_type" - ] - }, - "Key4feEnum": { - "enum": [ - "hardware_requests", - "printing", - "events", - "bookings", - "maintenance", - "members", - "api_access", - "devices", - "payments", - "stock", - "certifications" - ], - "type": "string", - "description": "* `hardware_requests` - Hardware requests\n* `printing` - Printing\n* `events` - Events\n* `bookings` - Bookings\n* `maintenance` - Maintenance\n* `members` - Members\n* `api_access` - API access\n* `devices` - Devices\n* `payments` - Payments\n* `stock` - Stock and procurement\n* `certifications` - Certifications" - }, - "KeyB30Enum": { - "enum": [ - "in_app", - "email", - "telegram", - "slack", - "mattermost", - "discord", - "webhook", - "native_push" - ], - "type": "string", - "description": "* `in_app` - Inbox\n* `email` - Email\n* `telegram` - Telegram\n* `slack` - Slack\n* `mattermost` - Mattermost\n* `discord` - Discord\n* `webhook` - Signed webhook\n* `native_push` - Native push" - }, - "Kind3bfEnum": { - "enum": [ - "dev_room", - "bench", - "meeting", - "other" - ], - "type": "string", - "description": "* `dev_room` - Development room\n* `bench` - Bench\n* `meeting` - Meeting room\n* `other` - Other" - }, - "KindB02Enum": { - "enum": [ - "rollback_in_place", - "disaster" - ], - "type": "string", - "description": "* `rollback_in_place` - Rollback in place\n* `disaster` - Disaster or cross-server" - }, - "KindE45Enum": { - "enum": [ - "printer", - "label_printer", - "generic" - ], - "type": "string", - "description": "* `printer` - Printer\n* `label_printer` - Label printer\n* `generic` - Generic" + "makerspace": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "can_create_machine": { + "type": "boolean", + "readOnly": true + } + }, + "required": [ + "can_create_machine", + "capability_config", + "icon", + "id", + "is_builtin", + "makerspace", + "managing_action", + "name", + "slug" + ] }, - "KindE56Enum": { - "enum": [ - "role", - "requester", - "members", - "user" - ], - "type": "string", - "description": "* `role` - Role\n* `requester` - Requester\n* `members` - All members\n* `user` - Named user" + "MachineTypeCreate": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "maxLength": 50, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "name": { + "type": "string", + "maxLength": 200 + }, + "icon": { + "type": "string", + "maxLength": 50 + }, + "capability_config": {} + }, + "required": [ + "name", + "slug" + ] }, - "KioskAssignBox": { + "MachineTypeOption": { "type": "object", "properties": { - "request_id": { + "id": { "type": "integer", - "minimum": 1 + "readOnly": true }, - "box_code": { - "type": "string" + "name": { + "type": "string", + "readOnly": true + }, + "is_active": { + "type": "boolean", + "readOnly": true + }, + "is_overridden": { + "type": "boolean", + "readOnly": true } }, "required": [ - "box_code", - "request_id" + "id", + "is_active", + "is_overridden", + "name" ] }, - "KioskEvidencePrepare": { + "MachineTypePolicy": { "type": "object", "properties": { - "evidence_type": { - "$ref": "#/components/schemas/EvidenceUrlRequestEvidenceTypeEnum" + "cert_reminder_days": { + "type": "integer", + "maximum": 365, + "minimum": 0, + "nullable": true }, - "content_type": { - "type": "string" + "reminder_lead_days": { + "type": "integer", + "maximum": 365, + "minimum": 0, + "nullable": true }, - "size_bytes": { + "grace_days": { "type": "integer", + "maximum": 365, "minimum": 0, "nullable": true } - }, - "required": [ - "content_type", - "evidence_type" - ] + } }, - "KioskOnlineCommand": { + "MachineTypePricing": { "type": "object", "properties": { - "version": { - "$ref": "#/components/schemas/VersionEnum" + "machine_type_id": { + "type": "integer", + "readOnly": true }, - "kind": { - "$ref": "#/components/schemas/KioskOnlineCommandKindEnum" + "rate_per_unit": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true }, - "payload": { - "type": "object", - "additionalProperties": {} + "flat_fee": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true + }, + "payment_enabled": { + "type": "boolean", + "readOnly": true } }, "required": [ - "kind", - "payload", - "version" + "flat_fee", + "machine_type_id", + "payment_enabled", + "rate_per_unit" ] }, - "KioskOnlineCommandKindEnum": { - "enum": [ - "assign_box", - "direct_handout", - "direct_return", - "resolve_qr", - "reviewed_issue", - "reviewed_return" - ], - "type": "string", - "description": "* `assign_box` - assign_box\n* `direct_handout` - direct_handout\n* `direct_return` - direct_return\n* `resolve_qr` - resolve_qr\n* `reviewed_issue` - reviewed_issue\n* `reviewed_return` - reviewed_return" - }, - "KioskOnlineCommandResult": { + "MachineTypePricingList": { "type": "object", "properties": { - "kind": { - "type": "string" + "currency": { + "type": "string", + "readOnly": true }, - "result": { - "type": "object", - "additionalProperties": {} + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineTypePricing" + }, + "readOnly": true } }, "required": [ - "kind", - "result" + "currency", + "results" ] }, - "KioskOperatorLease": { + "MachineTypePricingSet": { "type": "object", "properties": { - "lease_token": { - "type": "string" - }, - "lease_id": { + "rate_per_unit": { "type": "string", - "format": "uuid" + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "session_id": { + "flat_fee": { "type": "string", - "format": "uuid" + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "actor_id": { + "payment_enabled": { + "type": "boolean" + } + }, + "required": [ + "flat_fee", + "payment_enabled", + "rate_per_unit" + ] + }, + "MachineUsageCandidatesPage": { + "type": "object", + "properties": { + "count": { "type": "integer" }, - "expires_at": { + "next": { "type": "string", - "format": "date-time" + "format": "uri", + "nullable": true }, - "allowed_actions": { - "type": "array", - "items": { - "type": "string" - } + "previous": { + "type": "string", + "format": "uri", + "nullable": true }, - "permitted_operations": { + "results": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/OperatorCandidate" } } }, "required": [ - "actor_id", - "allowed_actions", - "expires_at", - "lease_id", - "lease_token", - "permitted_operations", - "session_id" + "count", + "next", + "previous", + "results" ] }, - "KioskStation": { + "MachineUsageEntry": { "type": "object", "properties": { "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "enabled": { - "type": "boolean" + "type": "integer", + "readOnly": true }, - "public_token": { + "hours": { "type": "string", - "format": "uuid", - "nullable": true + "format": "decimal", + "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$", + "readOnly": true }, - "version": { + "member": { "type": "integer", + "readOnly": true, "nullable": true }, - "station_url": { + "started_at": { "type": "string", + "format": "date-time", + "readOnly": true, "nullable": true }, - "rotated_at": { + "source": { + "allOf": [ + { + "$ref": "#/components/schemas/MachineUsageEntrySourceEnum" + } + ], + "readOnly": true + }, + "note": { "type": "string", - "format": "date-time", + "readOnly": true + }, + "logged_by_username": { + "type": "string", + "readOnly": true, "nullable": true }, "created_at": { "type": "string", - "format": "date-time" + "format": "date-time", + "readOnly": true } }, "required": [ "created_at", - "enabled", + "hours", "id", - "kind", - "name", - "public_token", - "rotated_at", - "station_url", - "version" - ] - }, - "KioskStationBootstrap": { - "type": "object", - "properties": { - "station": { - "$ref": "#/components/schemas/_StationRef" - }, - "makerspace": { - "$ref": "#/components/schemas/_MakerspaceRef" - }, - "session_expires_at": { - "type": "string", - "format": "date-time" - }, - "permitted_operations": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "makerspace", - "permitted_operations", - "session_expires_at", - "station" - ] - }, - "KioskStationCreate": { - "type": "object", - "properties": { - "name": { - "type": "string", - "maxLength": 160 - }, - "kind": { - "$ref": "#/components/schemas/KioskStationCreateKindEnum" - } - }, - "required": [ - "kind", - "name" + "logged_by_username", + "member", + "note", + "source", + "started_at" ] }, - "KioskStationCreateKindEnum": { + "MachineUsageEntrySourceEnum": { "enum": [ - "scanner", - "handover" + "manual", + "typed_manual" ], "type": "string", - "description": "* `scanner` - Scanner\n* `handover` - Handover" + "description": "* `manual` - Manual hours\n* `typed_manual` - Typed manual service" }, - "KioskStationPin": { + "MachineUsageHeatmapReport": { "type": "object", "properties": { - "pin": { - "type": "string", - "writeOnly": true, - "pattern": "^\\d{8}$" + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "makerspace_slug": { - "type": "string", - "maxLength": 80, - "pattern": "^[-a-zA-Z0-9_]+$" + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineHeatmapRow" + } } }, "required": [ - "pin" + "rows", + "typed_rows" ] }, - "KioskStationReveal": { + "MachineUsageMembersReport": { "type": "object", "properties": { - "current_password": { - "type": "string", - "writeOnly": true + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineMemberRow" + } } }, "required": [ - "current_password" + "rows", + "typed_rows" ] }, - "KioskStationRevealResponse": { + "MachineUsageV2Report": { "type": "object", "properties": { - "pin": { - "type": "string" + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "version": { - "type": "integer" + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineUsageV2Row" + } } }, "required": [ - "pin", - "version" + "rows", + "typed_rows" ] }, - "KioskStationSecret": { + "MachineUsageV2Row": { "type": "object", "properties": { - "id": { + "makerspace_id": { "type": "integer" }, - "name": { - "type": "string" + "period": { + "type": "string", + "format": "date", + "nullable": true }, - "kind": { - "type": "string" + "machine_id": { + "type": "integer", + "nullable": true }, - "enabled": { - "type": "boolean" + "machine_name": { + "type": "string", + "nullable": true }, - "public_token": { + "machine_type": { "type": "string", - "format": "uuid", "nullable": true }, - "version": { + "measure": { + "$ref": "#/components/schemas/MeasureEnum" + }, + "machine_type_id": { "type": "integer", "nullable": true }, - "station_url": { + "entries": { + "type": "integer" + }, + "service_hours": { "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$", "nullable": true }, - "rotated_at": { + "manual_hours": { "type": "string", - "format": "date-time", + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$", "nullable": true }, - "created_at": { + "booked_hours": { "type": "string", - "format": "date-time" - }, - "pin": { - "type": "string" + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$", + "nullable": true } }, "required": [ - "created_at", - "enabled", - "id", - "kind", - "name", - "pin", - "public_token", - "rotated_at", - "station_url", - "version" + "booked_hours", + "entries", + "machine_id", + "machine_name", + "machine_type", + "machine_type_id", + "manual_hours", + "measure", + "period", + "service_hours" ] }, - "KioskWorkLine": { + "MaintenanceActivityV2Report": { "type": "object", "properties": { - "item_id": { - "type": "integer" - }, - "label": { - "type": "string" - }, - "quantity": { - "type": "integer" + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "returned": { - "type": "integer" + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MaintenanceActivityV2Row" + } } }, "required": [ - "item_id", - "label", - "quantity", - "returned" + "rows", + "typed_rows" ] }, - "KioskWorkPackage": { + "MaintenanceActivityV2Row": { "type": "object", "properties": { - "kind": { - "type": "string" - }, - "id": { + "makerspace_id": { "type": "integer" }, - "status": { - "type": "string" + "period": { + "type": "string", + "format": "date", + "nullable": true }, - "display_label": { - "type": "string" + "machine_id": { + "type": "integer", + "nullable": true + }, + "machine_name": { + "type": "string", + "nullable": true + }, + "machine_type": { + "type": "string", + "nullable": true + }, + "is_active": { + "type": "boolean" + }, + "log_count": { + "type": "integer", + "nullable": true + }, + "costed_log_count": { + "type": "integer", + "nullable": true + }, + "total_cost": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$", + "nullable": true }, - "person_display": { - "type": "string" + "average_cost": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$", + "nullable": true }, - "box": { + "last_performed_at": { "type": "string", + "format": "date-time", "nullable": true }, - "lines": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KioskWorkLine" - } + "average_interval_days": { + "type": "number", + "format": "double", + "nullable": true }, - "kit_components": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KitExpectation" - } + "active_schedules": { + "type": "integer", + "nullable": true }, - "return_resolutions": { - "type": "array", - "items": { - "type": "string" - } + "overdue_schedules": { + "type": "integer", + "nullable": true }, - "expires_at": { + "currency": { "type": "string", - "format": "date-time" - }, - "command_age_limit_seconds": { - "type": "integer" - }, - "evidence_max_bytes": { - "type": "integer" + "nullable": true, + "description": "Unknown: legacy logs do not record currency. No cross-machine money total." } }, "required": [ - "box", - "command_age_limit_seconds", - "display_label", - "evidence_max_bytes", - "expires_at", - "id", - "kind", - "kit_components", - "lines", - "person_display", - "return_resolutions", - "status" + "active_schedules", + "average_cost", + "average_interval_days", + "costed_log_count", + "currency", + "is_active", + "last_performed_at", + "log_count", + "machine_id", + "machine_name", + "machine_type", + "overdue_schedules", + "period", + "total_cost" ] }, - "KioskWorkPackageRequest": { + "MaintenanceDocumentFinalize": { "type": "object", "properties": { - "kind": { - "$ref": "#/components/schemas/KioskWorkPackageRequestKindEnum" - }, - "id": { - "type": "integer", - "minimum": 1 + "object_key": { + "type": "string", + "maxLength": 500 } }, "required": [ - "id", - "kind" + "object_key" ] }, - "KioskWorkPackageRequestKindEnum": { - "enum": [ - "reviewed_request", - "direct_loan", - "direct_handout" - ], - "type": "string", - "description": "* `reviewed_request` - reviewed_request\n* `direct_loan` - direct_loan\n* `direct_handout` - direct_handout" - }, - "KitComponent": { + "MaintenanceDocumentPresign": { "type": "object", "properties": { - "key": { + "filename": { "type": "string", - "format": "uuid" + "maxLength": 255 }, - "label": { + "content_type": { "type": "string", - "maxLength": 200 - }, - "quantity": { - "type": "integer", - "maximum": 999, - "minimum": 1 + "maxLength": 100 } }, "required": [ - "label", - "quantity" + "content_type", + "filename" ] }, - "KitCount": { + "MaintenanceDocumentPresignResponse": { "type": "object", "properties": { - "key": { - "type": "string", - "format": "uuid" + "object_key": { + "type": "string" }, - "present": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "upload": { + "$ref": "#/components/schemas/MaintenanceDocumentUpload" } }, "required": [ - "key", - "present" + "object_key", + "upload" ] }, - "KitExpectation": { + "MaintenanceDocumentUpload": { "type": "object", "properties": { - "key": { + "url": { "type": "string", - "format": "uuid" - }, - "label": { - "type": "string" + "format": "uri" }, - "owner_label": { + "method": { "type": "string" }, - "quantity": { - "type": "integer" + "fields": { + "type": "object", + "additionalProperties": {} }, - "issue_present": { - "type": "integer" + "headers": { + "type": "object", + "additionalProperties": {} } }, "required": [ - "key", - "label", - "owner_label", - "quantity" + "url" ] }, - "KitPreview": { + "MaintenanceDocumentUrl": { "type": "object", "properties": { - "components": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KitExpectation" - } + "url": { + "type": "string", + "format": "uri" } }, "required": [ - "components" + "url" ] }, - "KitPreviewInput": { + "MaintenanceLog": { "type": "object", "properties": { - "phase": { - "allOf": [ - { - "$ref": "#/components/schemas/PhaseEnum" - } - ], - "default": "issue" - }, - "request_id": { + "id": { "type": "integer", - "minimum": 1 + "readOnly": true }, - "loan_id": { + "machine_id": { "type": "integer", - "minimum": 1 - }, - "box_code": { - "type": "string" + "readOnly": true }, - "container_id": { + "performed_by_id": { "type": "integer", - "minimum": 1, + "readOnly": true, "nullable": true }, - "qr_payloads": { - "type": "array", - "items": { - "type": "string", - "maxLength": 64 - }, - "maxItems": 50 - }, - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DirectLoanItem" - } - }, - "rejects": { - "type": "array", - "items": { - "$ref": "#/components/schemas/IssueReject" - } - }, - "resolutions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ReturnItemResolution" - } - } - } - }, - "LabelCommand": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "readOnly": true - }, - "command_id": { + "performed_at": { "type": "string", - "format": "uuid", + "format": "date-time", "readOnly": true }, - "command_status": { + "summary": { "type": "string", "readOnly": true }, - "device_id": { + "cost": { "type": "string", - "format": "uuid", - "readOnly": true - }, - "batch_id": { - "type": "integer", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", "readOnly": true, "nullable": true }, - "request_id": { + "parts_note": { "type": "string", - "format": "uuid", - "readOnly": true - }, - "batch_id_snapshot": { - "type": "integer", "readOnly": true }, - "batch_title_snapshot": { + "created_at": { "type": "string", + "format": "date-time", "readOnly": true }, - "item_count_snapshot": { + "documents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MaintenanceLogDocument" + }, + "readOnly": true + } + }, + "required": [ + "cost", + "created_at", + "documents", + "id", + "machine_id", + "parts_note", + "performed_at", + "performed_by_id", + "summary" + ] + }, + "MaintenanceLogDocument": { + "type": "object", + "properties": { + "id": { "type": "integer", "readOnly": true }, - "result": { - "allOf": [ - { - "$ref": "#/components/schemas/ResultEnum" - } - ], + "log_id": { + "type": "integer", "readOnly": true }, - "result_code": { + "object_key": { "type": "string", "readOnly": true }, - "result_message": { - "type": "string", + "size_bytes": { + "type": "integer", "readOnly": true }, - "completed_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "requested_by_id": { + "uploaded_by_id": { "type": "integer", "readOnly": true, "nullable": true @@ -80028,208 +102238,174 @@ } }, "required": [ - "batch_id", - "batch_id_snapshot", - "batch_title_snapshot", - "command_id", - "command_status", - "completed_at", "created_at", - "device_id", "id", - "item_count_snapshot", - "request_id", - "requested_by_id", - "result", - "result_code", - "result_message" + "log_id", + "object_key", + "size_bytes", + "uploaded_by_id" ] }, - "LabelCreate": { + "MaintenanceLogList": { "type": "object", "properties": { - "device_id": { + "count": { + "type": "integer" + }, + "next": { "type": "string", - "format": "uuid" + "nullable": true }, - "request_id": { + "previous": { "type": "string", - "format": "uuid" + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MaintenanceLog" + } } }, "required": [ - "device_id", - "request_id" + "count", + "results" ] }, - "LabelReceipt": { + "MaintenanceLogWrite": { "type": "object", "properties": { - "operation_id": { - "type": "string", - "format": "uuid" + "summary": { + "type": "string" }, - "label_command_id": { + "performed_at": { "type": "string", - "format": "uuid" - }, - "outcome": { - "$ref": "#/components/schemas/OutcomeA1aEnum" - }, - "result_code": { - "$ref": "#/components/schemas/ResultCodeEnum" - }, - "response_result": { - "$ref": "#/components/schemas/ResponseResultEnum" - }, - "response_command_status": { - "type": "string" + "format": "date-time" }, - "response_completed_at": { + "cost": { "type": "string", - "format": "date-time", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", "nullable": true - } - }, - "required": [ - "label_command_id", - "operation_id", - "outcome", - "response_command_status", - "response_completed_at", - "response_result", - "result_code" - ] - }, - "LabelResult": { - "type": "object", - "properties": { - "operation_id": { + }, + "parts_note": { "type": "string", - "format": "uuid" + "default": "" }, - "outcome": { - "$ref": "#/components/schemas/OutcomeA1aEnum" + "set_idle": { + "type": "boolean", + "default": false }, - "result_code": { - "$ref": "#/components/schemas/ResultCodeEnum" - } - }, - "required": [ - "operation_id", - "outcome", - "result_code" - ] - }, - "LeaseResponse": { - "type": "object", - "properties": { - "command": { - "allOf": [ - { - "$ref": "#/components/schemas/CommandLease" - } - ], - "nullable": true + "schedule_id": { + "type": "integer", + "minimum": 1, + "writeOnly": true } }, "required": [ - "command" + "summary" ] }, - "LedgerLine": { + "MaintenanceSchedule": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" + "id": { + "type": "integer", + "readOnly": true }, - "currency": { - "type": "string" + "machine_id": { + "type": "integer", + "readOnly": true }, - "collected_amount": { + "description": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + "readOnly": true }, - "refunded_amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + "interval_days": { + "type": "integer", + "readOnly": true }, - "date": { + "next_due": { "type": "string", - "format": "date-time" - }, - "row_kind": { - "type": "string" - }, - "row_id": { - "type": "integer" - }, - "payment_id": { - "type": "integer" + "format": "date", + "readOnly": true }, - "document_number": { - "type": "string" + "anchor": { + "allOf": [ + { + "$ref": "#/components/schemas/AnchorEnum" + } + ], + "readOnly": true }, - "subject_type": { - "type": "string" + "trigger": { + "allOf": [ + { + "$ref": "#/components/schemas/MaintenanceScheduleTriggerEnum" + } + ], + "readOnly": true }, - "payer_type": { - "type": "string" + "usage_hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true, + "nullable": true }, - "payer_id": { + "job_count": { "type": "integer", + "readOnly": true, "nullable": true }, - "status": { - "type": "string" - }, - "amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" - }, - "owed_amount": { + "meter_started_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + "format": "date-time", + "readOnly": true }, - "method": { - "type": "string" + "is_active": { + "type": "boolean", + "readOnly": true }, - "reference": { - "type": "string" + "created_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true }, - "effective": { - "type": "boolean" + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true }, - "refund_status": { - "type": "string" + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "overdue": { + "type": "boolean", + "readOnly": true } }, "required": [ - "amount", - "collected_amount", - "currency", - "date", - "document_number", - "effective", - "method", - "owed_amount", - "payer_id", - "payer_type", - "payment_id", - "reference", - "refund_status", - "refunded_amount", - "row_id", - "row_kind", - "status", - "subject_type" + "anchor", + "created_at", + "created_by_id", + "description", + "id", + "interval_days", + "is_active", + "job_count", + "machine_id", + "meter_started_at", + "next_due", + "overdue", + "trigger", + "updated_at", + "usage_hours" ] }, - "LedgerResponse": { + "MaintenanceScheduleList": { "type": "object", "properties": { "count": { @@ -80246,7 +102422,7 @@ "results": { "type": "array", "items": { - "$ref": "#/components/schemas/LedgerRow" + "$ref": "#/components/schemas/MaintenanceSchedule" } } }, @@ -80255,3852 +102431,3902 @@ "results" ] }, - "LedgerRow": { + "MaintenanceScheduleTriggerEnum": { + "enum": [ + "days", + "usage", + "both" + ], + "type": "string", + "description": "* `days` - Days\n* `usage` - Usage\n* `both` - Days or usage" + }, + "MaintenanceScheduleWrite": { "type": "object", "properties": { - "source": { - "$ref": "#/components/schemas/LedgerRowSourceEnum" - }, - "item_name": { - "type": "string" - }, - "container": { - "type": "string", - "nullable": true - }, - "holder": { + "description": { "type": "string" }, - "quantity": { - "type": "integer" - }, - "units": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LedgerUnit" - } + "interval_days": { + "type": "integer", + "minimum": 1 }, - "target_label": { + "next_due": { "type": "string", - "nullable": true + "format": "date" }, - "since": { - "type": "string", - "format": "date-time", - "nullable": true + "trigger": { + "$ref": "#/components/schemas/MaintenanceScheduleWriteTriggerEnum" }, - "due": { + "usage_hours": { "type": "string", - "format": "date-time", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", "nullable": true }, - "makerspace_id": { - "type": "integer" - }, - "reference_id": { - "type": "integer" + "job_count": { + "type": "integer", + "minimum": 1, + "nullable": true }, - "status": { - "type": "string" + "anchor": { + "$ref": "#/components/schemas/AnchorEnum" } }, "required": [ - "due", - "holder", - "item_name", - "makerspace_id", - "quantity", - "reference_id", - "since", - "source", - "status", - "units" + "description", + "interval_days", + "next_due" ] }, - "LedgerRowSourceEnum": { + "MaintenanceScheduleWriteTriggerEnum": { "enum": [ - "request", - "self_checkout", - "direct_handout" + "days", + "usage", + "both" ], "type": "string", - "description": "* `request` - request\n* `self_checkout` - self_checkout\n* `direct_handout` - direct_handout" - }, - "LedgerUnit": { - "type": "object", - "properties": { - "asset_tag": { - "type": "string" - }, - "serial_number": { - "type": "string" - } - }, - "required": [ - "asset_tag", - "serial_number" - ] + "description": "* `days` - days\n* `usage` - usage\n* `both` - both" }, - "LegacyResetPasswordConfirm": { + "Makerspace": { "type": "object", "properties": { - "uid": { - "type": "string" + "organization_id": { + "type": "integer", + "minimum": 1, + "writeOnly": true, + "description": "Existing active owner organization ID. On create, supply this or organization_name, exactly one; refused on update." }, - "token": { + "organization_name": { "type": "string", + "writeOnly": true, + "description": "Create a new owner organization with this name. On create, supply this or organization_id, exactly one; refused on update.", + "maxLength": 200 + }, + "creation_setup": { + "allOf": [ + { + "$ref": "#/components/schemas/MakerspaceCreationSetup" + } + ], "writeOnly": true }, - "new_password": { - "type": "string", + "creation_invitation": { + "allOf": [ + { + "$ref": "#/components/schemas/CreationInviteDelivery" + } + ], + "readOnly": true, + "nullable": true + }, + "install_profile_id": { + "type": "integer", + "minimum": 1, "writeOnly": true - } - }, - "required": [ - "new_password", - "token", - "uid" - ] - }, - "LegacyRoleEnum": { - "enum": [ - "space_manager", - "inventory_manager", - "print_manager", - "machine_manager" - ], - "type": "string", - "description": "* `space_manager` - Space Manager\n* `inventory_manager` - Inventory Manager\n* `print_manager` - Print Manager\n* `machine_manager` - Machine Manager" - }, - "LendingHistoryActor": { - "type": "object", - "properties": { - "username": { - "type": "string" }, - "role": { - "type": "string" - } - }, - "required": [ - "role", - "username" - ] - }, - "LendingHistoryEntry": { - "type": "object", - "properties": { - "id": { - "type": "integer" + "id": { + "type": "integer", + "readOnly": true + }, + "name": { + "type": "string", + "maxLength": 200 + }, + "public_code": { + "type": "string", + "pattern": "^[A-Z0-9]{4}$", + "maxLength": 4 + }, + "slug": { + "type": "string", + "maxLength": 50, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "location": { + "type": "string", + "maxLength": 200 + }, + "timezone": { + "type": "string", + "readOnly": true + }, + "country": { + "type": "string", + "readOnly": true + }, + "opening_hours": { + "oneOf": [ + { + "type": "object", + "maxProperties": 0 + }, + { + "type": "object", + "required": [ + "weekly", + "exceptions" + ], + "additionalProperties": false, + "properties": { + "weekly": { + "oneOf": [ + { + "type": "object", + "maxProperties": 0 + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ], + "properties": { + "mon": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "tue": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "wed": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "thu": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "fri": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "sat": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "sun": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + } + } + } + ] + }, + "exceptions": { + "type": "object", + "maxProperties": 366, + "additionalProperties": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "description": "YYYY-MM-DD keys; each value replaces that date's weekly hours." + } + } + } + ], + "description": "Empty object means always open. Empty weekly hours mean always open except dated overrides. Empty interval lists mean closed. Sorted intervals use local wall time; split overnight hours across days.", + "readOnly": true + }, + "map_url": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "maxLength": 200 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "geofence_latitude": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,3}(?:\\.\\d{0,6})?$", + "nullable": true + }, + "geofence_longitude": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,3}(?:\\.\\d{0,6})?$", + "nullable": true + }, + "geofence_radius_m": { + "type": "integer", + "maximum": 2147483647, + "minimum": 1 + }, + "geofence_enabled": { + "type": "boolean" + }, + "public_inventory_enabled": { + "type": "boolean" + }, + "public_site_enabled": { + "type": "boolean" + }, + "public_landing_page": { + "$ref": "#/components/schemas/PublicLandingPage6a0Enum" + }, + "public_stats_enabled": { + "type": "boolean" + }, + "public_stats_show_holder_names": { + "type": "boolean" + }, + "public_print_status_lookup_policy": { + "$ref": "#/components/schemas/PublicPrintStatusLookupPolicyEnum" + }, + "membership_policy": { + "$ref": "#/components/schemas/MembershipPolicyEnum" + }, + "membership_dues_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "username": { - "type": "string" + "referrals_enabled": { + "type": "boolean" }, - "issued_at": { + "lapsed_members_cannot_borrow": { + "type": "boolean" + }, + "anonymous_requests_enabled": { + "type": "boolean" + }, + "request_access": { "type": "string", - "format": "date-time" + "readOnly": true }, - "quantity": { - "type": "integer" + "filament_low_stock_threshold_grams": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$" }, - "issued_by": { + "superadmin_access_enabled": { + "type": "boolean" + }, + "archive_custody_state": { "allOf": [ { - "$ref": "#/components/schemas/LendingHistoryActor" + "$ref": "#/components/schemas/ArchiveCustodyStateEnum" } ], - "nullable": true + "nullable": true, + "readOnly": true }, - "accepted_by": { + "staff_notifications_enabled": { + "type": "boolean" + }, + "booking_requester_notifications_enabled": { + "type": "boolean" + }, + "event_default_registration_audience": { + "$ref": "#/components/schemas/EventDefaultRegistrationAudienceEnum" + }, + "event_reminder_lead_hours": { + "type": "array", + "items": { + "type": "integer", + "maximum": 8760, + "minimum": 1 + }, + "maxItems": 100 + }, + "booking_reminder_lead_hours": { + "type": "array", + "items": { + "type": "integer", + "maximum": 8760, + "minimum": 1 + }, + "maxItems": 100 + }, + "logo_key": { + "type": "string", + "readOnly": true + }, + "logo_url": { + "type": "string", + "format": "uri", + "nullable": true, + "readOnly": true + }, + "cover_image_key": { + "type": "string", + "readOnly": true + }, + "cover_image_url": { + "type": "string", + "format": "uri", + "nullable": true, + "readOnly": true + }, + "frontend_domain": { + "type": "string", + "nullable": true, + "maxLength": 255 + }, + "frontend_domain_status": { "allOf": [ { - "$ref": "#/components/schemas/LendingHistoryActor" + "$ref": "#/components/schemas/FrontendDomainStatusEnum" } ], + "readOnly": true + }, + "domain_verified_at": { + "type": "string", + "format": "date-time", + "readOnly": true, "nullable": true - } - }, - "required": [ - "accepted_by", - "id", - "issued_at", - "issued_by", - "quantity", - "username" - ] - }, - "LendingHistoryResponse": { - "type": "object", - "properties": { - "product_id": { - "type": "integer" }, - "last_borrower": { - "allOf": [ - { - "$ref": "#/components/schemas/LendingHistoryEntry" + "domain_verification_token": { + "type": "string", + "readOnly": true + }, + "domain_verification_record": { + "type": "object", + "nullable": true, + "properties": { + "host": { + "type": "string" + }, + "type": { + "type": "string" + }, + "value": { + "type": "string" } - ], - "nullable": true + }, + "readOnly": true }, - "recent": { + "platform_hosting": { + "type": "boolean", + "readOnly": true + }, + "is_platform_subdomain": { + "type": "boolean", + "readOnly": true + }, + "hidden_from_central_directory": { + "type": "boolean" + }, + "public_api_key": { + "type": "string", + "readOnly": true + }, + "cors_allowed_origins": { + "readOnly": true + }, + "enabled_modules": { "type": "array", "items": { - "$ref": "#/components/schemas/LendingHistoryEntry" + "type": "string" + }, + "readOnly": true + }, + "unavailable_apps": { + "type": "array", + "items": { + "type": "string" + }, + "readOnly": true + }, + "resource_limit_overrides": {}, + "enabled_features": {}, + "theme_config": { + "type": "object", + "additionalProperties": false, + "description": "Public/member brand colours. Labels and light/dark hover, active, soft and link colours are derived automatically with accessible fallbacks. Well-formed #RGB or #RRGGBB colours are accepted; only malformed colour values are rejected. Blank colours use Maker Teal. Staff chrome and status colours stay fixed.", + "properties": { + "mode": { + "type": "string", + "enum": [ + "system", + "light", + "dark" + ] + }, + "primary_color": { + "type": "string", + "pattern": "^$|^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" + }, + "accent_color": { + "type": "string", + "pattern": "^$|^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" + }, + "logo_url": { + "type": "string", + "maxLength": 2048 + } } - } - }, - "required": [ - "last_borrower", - "product_id", - "recent" - ] - }, - "LevelEnum": { - "enum": [ - "info", - "warning", - "critical" - ], - "type": "string", - "description": "* `info` - Info\n* `warning` - Warning\n* `critical` - Critical" - }, - "LinkMachineConsumable": { - "type": "object", - "properties": { - "measurement": { - "$ref": "#/components/schemas/Measurement883Enum" }, - "product_id": { - "type": "integer", - "nullable": true + "branding_config": { + "readOnly": true }, - "label": { + "public_display_name": { "type": "string", + "writeOnly": true, "maxLength": 200 }, - "remaining": { + "telegram_group_chat_id": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "maxLength": 64 }, - "low_threshold": { + "telegram_bot_token": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "nullable": true + "writeOnly": true }, - "note": { - "type": "string", - "maxLength": 255 - } - }, - "required": [ - "measurement" - ] - }, - "LoanDepositModeEnum": { - "enum": [ - "none", - "fixed", - "per_product" - ], - "type": "string", - "description": "* `none` - No deposit\n* `fixed` - Fixed amount per loan\n* `per_product` - Sum of product deposits" - }, - "LoanRenewal": { - "type": "object", - "properties": { - "id": { - "type": "integer" + "telegram_bot_token_set": { + "type": "boolean", + "readOnly": true }, - "return_due_at": { + "smtp_host": { "type": "string", - "format": "date-time" + "maxLength": 200 }, - "renewal_count": { - "type": "integer" - } - }, - "required": [ - "id", - "renewal_count", - "return_due_at" - ] - }, - "LoanRenewalModeEnum": { - "enum": [ - "off", - "auto" - ], - "type": "string", - "description": "* `off` - Off\n* `auto` - Automatic within policy" - }, - "LocationKindEnum": { - "enum": [ - "indoor", - "outdoor", - "other" - ], - "type": "string", - "description": "* `indoor` - Indoor\n* `outdoor` - Outdoor\n* `other` - Other" - }, - "LogError": { - "type": "object", - "properties": { - "severity": { + "smtp_port": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "smtp_username": { "type": "string", - "maxLength": 16 + "maxLength": 200 }, - "message": { - "type": "string" - } - }, - "required": [ - "message", - "severity" - ] - }, - "LogMachineConsumption": { - "type": "object", - "properties": { - "quantity": { + "smtp_password": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - } - }, - "required": [ - "quantity" - ] - }, - "LogUsage": { - "type": "object", - "properties": { - "hours": { + "writeOnly": true + }, + "smtp_password_set": { + "type": "boolean", + "readOnly": true + }, + "smtp_use_tls": { + "type": "boolean" + }, + "smtp_use_ssl": { + "type": "boolean" + }, + "smtp_from_email": { + "oneOf": [ + { + "type": "string", + "format": "email", + "maxLength": 254 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "slack_webhook_url": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$" + "writeOnly": true, + "maxLength": 2048 }, - "note": { + "slack_webhook_url_set": { + "type": "boolean", + "readOnly": true + }, + "mattermost_webhook_url": { "type": "string", - "maxLength": 255 - } - }, - "required": [ - "hours" - ] - }, - "LoginRequest": { - "type": "object", - "properties": { - "username": { - "type": "string" + "writeOnly": true, + "maxLength": 2048 }, - "password": { + "mattermost_webhook_url_set": { + "type": "boolean", + "readOnly": true + }, + "discord_webhook_url": { "type": "string", - "writeOnly": true + "writeOnly": true, + "maxLength": 2048 }, - "surface": { - "$ref": "#/components/schemas/LoginRequestSurfaceEnum" - } - }, - "required": [ - "password", - "username" - ] - }, - "LoginRequestSurfaceEnum": { - "enum": [ - "member", - "staff" - ], - "type": "string", - "description": "* `member` - member\n* `staff` - staff" - }, - "LoginResponse": { - "type": "object", - "properties": { - "user": { - "$ref": "#/components/schemas/AuthUserPayload" + "discord_webhook_url_set": { + "type": "boolean", + "readOnly": true }, - "access": { - "type": "string" + "default_loan_days": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 }, - "surface": { - "$ref": "#/components/schemas/LoginResponseSurfaceEnum" - } - }, - "required": [ - "access", - "surface", - "user" - ] - }, - "LoginResponseSurfaceEnum": { - "enum": [ - "member", - "staff", - "staff_api", - "verification_only" - ], - "type": "string", - "description": "* `member` - member\n* `staff` - staff\n* `staff_api` - staff_api\n* `verification_only` - verification_only" - }, - "LogoutResponse": { - "type": "object", - "properties": { - "detail": { - "type": "string" - } - }, - "required": [ - "detail" - ] - }, - "MFAChallenge": { - "type": "object", - "properties": { - "challenge": { + "presence_preset_minutes": {}, + "created_at": { "type": "string", - "writeOnly": true, - "maxLength": 200, - "minLength": 20 + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true } }, "required": [ - "challenge" + "archive_custody_state", + "branding_config", + "cors_allowed_origins", + "country", + "cover_image_key", + "cover_image_url", + "created_at", + "creation_invitation", + "discord_webhook_url_set", + "domain_verification_record", + "domain_verification_token", + "domain_verified_at", + "enabled_modules", + "frontend_domain_status", + "id", + "is_platform_subdomain", + "logo_key", + "logo_url", + "mattermost_webhook_url_set", + "name", + "opening_hours", + "platform_hosting", + "public_api_key", + "request_access", + "slack_webhook_url_set", + "slug", + "smtp_password_set", + "telegram_bot_token_set", + "timezone", + "unavailable_apps", + "updated_at" ] }, - "MFAChallengeResponse": { + "MakerspaceArchiveRequest": { "type": "object", "properties": { - "mfa_required": { - "type": "boolean" + "id": { + "type": "integer", + "readOnly": true }, - "enrollment_required": { - "type": "boolean" + "makerspace": { + "type": "integer", + "readOnly": true }, - "challenge": { - "type": "string" + "requested_by": { + "type": "integer", + "readOnly": true, + "nullable": true }, - "expires_in": { - "type": "integer" - } - }, - "required": [ - "challenge", - "enrollment_required", - "expires_in", - "mfa_required" - ] - }, - "MFACompleted": { - "type": "object", - "properties": { - "user": { - "$ref": "#/components/schemas/AuthUserPayload" + "requested_by_username": { + "type": "string", + "readOnly": true, + "nullable": true }, - "access": { - "type": "string" + "requested_at": { + "type": "string", + "format": "date-time", + "readOnly": true }, - "surface": { - "type": "string" + "resolved_by": { + "type": "integer", + "readOnly": true, + "nullable": true }, - "refresh": { - "type": "string" + "resolved_by_username": { + "type": "string", + "readOnly": true, + "nullable": true }, - "device_grant": { - "type": "object", - "additionalProperties": {} + "resolved_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true }, - "recovery_codes": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "access", - "surface", - "user" - ] - }, - "MFAEnrollment": { - "type": "object", - "properties": { - "secret": { - "type": "string" + "reason": { + "type": "string", + "description": "Do not include personal data. Maximum 2,000 characters.", + "maxLength": 2000 }, - "provisioning_uri": { - "type": "string" + "resolution_note": { + "type": "string", + "readOnly": true }, - "qr_data_url": { - "type": "string" + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/MakerspaceArchiveRequestStatusEnum" + } + ], + "readOnly": true, + "default": "pending" } }, "required": [ - "provisioning_uri", - "qr_data_url", - "secret" + "id", + "makerspace", + "reason", + "requested_at", + "requested_by", + "requested_by_username", + "resolution_note", + "resolved_at", + "resolved_by", + "resolved_by_username", + "status" ] }, - "MFAPassword": { - "type": "object", - "properties": { - "password": { - "type": "string", - "writeOnly": true, - "maxLength": 1024 - } - }, - "required": [ - "password" - ] + "MakerspaceArchiveRequestStatusEnum": { + "enum": [ + "pending", + "approved", + "declined", + "withdrawn" + ], + "type": "string", + "description": "* `pending` - Pending\n* `approved` - Approved\n* `declined` - Declined\n* `withdrawn` - Withdrawn" }, - "MFARecovery": { + "MakerspaceCreationSetup": { "type": "object", "properties": { - "recovery_codes": { + "profile": { + "$ref": "#/components/schemas/CreationProfile" + }, + "modules": { "type": "array", "items": { - "type": "string" - } - } - }, - "required": [ - "recovery_codes" - ] - }, - "MFAStatus": { - "type": "object", - "properties": { - "available": { - "type": "boolean" - }, - "enabled": { - "type": "boolean" + "$ref": "#/components/schemas/ModulesEnum" + }, + "maxItems": 60 }, - "required": { - "type": "boolean" + "first_staff_invite": { + "$ref": "#/components/schemas/FirstStaffInvite" } - }, - "required": [ - "available", - "enabled", - "required" - ] + } }, - "MFAVerify": { + "MakerspacePaymentSettings": { "type": "object", "properties": { - "challenge": { + "default_currency": { "type": "string", - "writeOnly": true, - "maxLength": 200, - "minLength": 20 + "maxLength": 3 }, - "code": { + "stripe_publishable_key": { "type": "string", "writeOnly": true, - "maxLength": 64, - "minLength": 6 - } - }, - "required": [ - "challenge", - "code" - ] - }, - "Machine": { - "type": "object", - "properties": { - "id": { - "type": "integer", + "maxLength": 255 + }, + "stripe_publishable_key_set": { + "type": "boolean", "readOnly": true }, - "makerspace": { - "type": "integer", + "stripe_secret_key": { + "type": "string", + "writeOnly": true + }, + "stripe_secret_key_set": { + "type": "boolean", "readOnly": true }, - "machine_type": { + "stripe_webhook_secret": { + "type": "string", + "writeOnly": true + }, + "stripe_webhook_secret_set": { + "type": "boolean", + "readOnly": true + }, + "connect_account_id": { + "type": "string", + "readOnly": true, + "nullable": true + }, + "connect_status": { "allOf": [ { - "$ref": "#/components/schemas/MachineType" + "$ref": "#/components/schemas/ConnectStatusEnum" } ], "readOnly": true }, - "machine_type_id": { - "type": "integer", - "writeOnly": true + "connect_charges_enabled": { + "type": "boolean", + "readOnly": true }, - "name": { + "connect_payouts_enabled": { + "type": "boolean", + "readOnly": true + }, + "connect_status_updated_at": { "type": "string", - "maxLength": 200 + "format": "date-time", + "readOnly": true }, - "location": { + "effective_mode": { "type": "string", - "maxLength": 200 + "readOnly": true }, - "notes": { - "type": "string" + "loan_deposit_mode": { + "$ref": "#/components/schemas/LoanDepositModeEnum" }, - "status": { - "allOf": [ + "loan_deposit_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "loan_late_fee_per_day": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "loan_late_fee_cap": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "loan_grace_days": { + "type": "integer", + "maximum": 32767, + "minimum": 0 + }, + "loan_deposit_blocks_issue": { + "type": "boolean" + } + }, + "required": [ + "connect_account_id", + "connect_charges_enabled", + "connect_payouts_enabled", + "connect_status", + "connect_status_updated_at", + "effective_mode", + "stripe_publishable_key_set", + "stripe_secret_key_set", + "stripe_webhook_secret_set" + ] + }, + "MakerspaceProfile": { + "type": "object", + "properties": { + "timezone": { + "type": "string", + "maxLength": 100 + }, + "country": { + "type": "string", + "maxLength": 2 + }, + "opening_hours": { + "oneOf": [ { - "$ref": "#/components/schemas/MachineStatusEnum" + "type": "object", + "maxProperties": 0 + }, + { + "type": "object", + "required": [ + "weekly", + "exceptions" + ], + "additionalProperties": false, + "properties": { + "weekly": { + "oneOf": [ + { + "type": "object", + "maxProperties": 0 + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ], + "properties": { + "mon": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "tue": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "wed": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "thu": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "fri": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "sat": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "sun": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + } + } + } + ] + }, + "exceptions": { + "type": "object", + "maxProperties": 366, + "additionalProperties": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "description": "YYYY-MM-DD keys; each value replaces that date's weekly hours." + } + } } ], - "readOnly": true - }, - "firmware_version": { - "type": "string", - "maxLength": 100 - }, - "camera_feed_url": { - "oneOf": [ - { - "type": "string", - "format": "uri", - "maxLength": 200 - }, - { - "type": "string", - "maxLength": 0 - } - ] - }, - "status_since": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "public_fields": {}, - "type_payload": {}, - "image_url": { - "type": "string", - "nullable": true, - "readOnly": true + "description": "Empty object means always open. Empty weekly hours mean always open except dated overrides. Empty interval lists mean closed. Sorted intervals use local wall time; split overnight hours across days." }, - "warranty_status": { + "default_currency": { "type": "string", - "readOnly": true - }, - "is_public": { - "type": "boolean", - "readOnly": true - }, - "is_active": { - "type": "boolean", - "readOnly": true - }, - "usage_hours": { - "type": "number", - "format": "double", - "readOnly": true - }, - "can_operate": { - "type": "boolean", - "readOnly": true - }, - "can_edit": { - "type": "boolean", - "readOnly": true - }, - "can_delegate": { - "type": "boolean", - "readOnly": true - }, - "can_retire": { - "type": "boolean", - "readOnly": true - }, - "can_unretire": { - "type": "boolean", - "readOnly": true - }, - "can_manage": { - "type": "boolean", - "readOnly": true + "maxLength": 3 }, - "created_at": { - "type": "string", - "format": "date-time", + "timezone_choices": { + "type": "array", + "items": { + "type": "string" + }, "readOnly": true }, - "updated_at": { - "type": "string", - "format": "date-time", + "country_choices": { + "type": "array", + "items": { + "type": "string" + }, "readOnly": true } }, "required": [ - "can_delegate", - "can_edit", - "can_manage", - "can_operate", - "can_retire", - "can_unretire", - "created_at", - "id", - "image_url", - "is_active", - "is_public", - "machine_type", - "machine_type_id", - "makerspace", - "name", - "status", - "status_since", - "updated_at", - "usage_hours", - "warranty_status" + "country", + "country_choices", + "default_currency", + "opening_hours", + "timezone", + "timezone_choices" ] }, - "MachineConsumable": { + "MakerspaceUsage": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "measurement": { - "allOf": [ - { - "$ref": "#/components/schemas/Measurement883Enum" - } - ], - "readOnly": true - }, - "product": { - "type": "integer", - "readOnly": true, - "nullable": true - }, - "product_name": { - "type": "string", - "nullable": true, - "readOnly": true - }, - "available": { - "type": "integer", - "nullable": true, - "readOnly": true - }, - "remaining": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "nullable": true, - "readOnly": true - }, - "label": { - "type": "string", - "readOnly": true - }, - "low_threshold": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "readOnly": true, - "nullable": true + "makerspace_id": { + "type": "integer" }, - "note": { - "type": "string", - "readOnly": true + "managed": { + "type": "boolean" }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "resources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UsageResource" + } } }, "required": [ - "available", - "created_at", - "id", - "label", - "low_threshold", - "measurement", - "note", - "product", - "product_name", - "remaining" + "makerspace_id", + "managed", + "resources" ] }, - "MachineDocument": { + "ManagedPolicyMarker": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "doc_type": { - "allOf": [ - { - "$ref": "#/components/schemas/DocTypeEnum" - } - ], - "readOnly": true - }, - "original_filename": { - "type": "string", - "readOnly": true + "feature": { + "type": "string" }, - "content_type": { - "type": "string", - "readOnly": true + "event": { + "type": "string" }, - "size_bytes": { + "count": { "type": "integer", - "readOnly": true - }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "minimum": 1 } }, "required": [ - "content_type", - "created_at", - "doc_type", - "id", - "original_filename", - "size_bytes" + "count", + "event", + "feature" ] }, - "MachineErrorLog": { + "ManualRefundError": { + "oneOf": [ + { + "$ref": "#/components/schemas/HardwareRequestError" + }, + { + "$ref": "#/components/schemas/ManualRefundValidationError" + } + ] + }, + "ManualRefundReceipt": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "severity": { - "allOf": [ - { - "$ref": "#/components/schemas/SeverityEnum" - } - ], - "readOnly": true - }, - "message": { - "type": "string", - "readOnly": true + "method": { + "$ref": "#/components/schemas/Method2edEnum" }, - "logged_by_username": { + "reference": { "type": "string", - "readOnly": true, - "nullable": true + "default": "", + "maxLength": 64 }, - "created_at": { + "refunded_at": { "type": "string", - "format": "date-time", - "readOnly": true + "format": "date-time" } }, "required": [ - "created_at", - "id", - "logged_by_username", - "message", - "severity" + "method", + "refunded_at" ] }, - "MachineListResponse": { + "ManualRefundValidationError": { "type": "object", "properties": { - "count": { - "type": "integer" + "amount": { + "type": "array", + "items": { + "type": "string" + } }, - "results": { + "reason": { "type": "array", "items": { - "$ref": "#/components/schemas/Machine" + "type": "string" + } + }, + "receipt": {}, + "non_field_errors": { + "type": "array", + "items": { + "type": "string" } } - }, - "required": [ - "count", - "results" - ] + } }, - "MachineOperator": { + "ManualSettlement": { "type": "object", + "description": "The receipt required to mark a charge paid offline.\n\nMethod and received date are REQUIRED: the point of the ledger is that a settled\ncharge can always say how and when the money arrived, and an optional field would\nquietly reproduce the bare boolean it replaces. `reference` stays optional because\ncash genuinely has none.", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "user": { - "type": "integer", - "readOnly": true - }, - "username": { - "type": "string", - "readOnly": true - }, - "access_level": { - "allOf": [ - { - "$ref": "#/components/schemas/AccessLevelEnum" - } - ], - "readOnly": true + "method": { + "$ref": "#/components/schemas/Method2edEnum" }, - "assigned_by_username": { + "reference": { "type": "string", - "readOnly": true, - "nullable": true + "default": "", + "maxLength": 64 }, - "assigned_at": { + "received_at": { "type": "string", - "format": "date-time", - "readOnly": true + "format": "date-time" } }, "required": [ - "access_level", - "assigned_at", - "assigned_by_username", - "id", - "user", - "username" + "method", + "received_at" ] }, - "MachineScopeOption": { + "Material": { "type": "object", "properties": { "id": { - "type": "integer" + "type": "integer", + "readOnly": true }, - "label": { - "type": "string" + "machine_type": { + "type": "integer" }, - "is_builtin": { - "type": "boolean" + "name": { + "type": "string", + "maxLength": 100 }, + "colours": {}, "is_active": { "type": "boolean" } }, "required": [ + "colours", "id", - "label" - ] - }, - "MachineServiceConsumption": { - "type": "object", - "properties": { - "makerspace_id": { - "type": "integer" - }, - "machine_id": { - "type": "integer" - }, - "machine_name": { - "type": "string" - }, - "machine_type": { - "type": "string", - "nullable": true - }, - "measurement": { - "$ref": "#/components/schemas/MachineServiceConsumptionMeasurementEnum" - }, - "product_id": { - "type": "integer", - "nullable": true - }, - "product_label": { - "type": "string" - }, - "completed_amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - }, - "failed_partial_amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - }, - "total_used": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - } - }, - "required": [ - "completed_amount", - "failed_partial_amount", - "machine_id", - "machine_name", "machine_type", - "measurement", - "product_id", - "product_label", - "total_used" + "name" ] }, - "MachineServiceConsumptionMeasurementEnum": { + "MeasureEnum": { + "enum": [ + "service", + "manual", + "booked" + ], + "type": "string", + "description": "* `service` - service\n* `manual` - manual\n* `booked` - booked" + }, + "Measurement883Enum": { "enum": [ "count", "grams" ], "type": "string", - "description": "* `count` - count\n* `grams` - grams" - }, - "MachineServiceFailure": { - "type": "object", - "properties": { - "makerspace_id": { - "type": "integer" - }, - "machine_id": { - "type": "integer" - }, - "machine_name": { - "type": "string" - }, - "machine_type": { - "type": "string", - "nullable": true - }, - "outcome": { - "type": "string" - }, - "failed_count": { - "type": "integer" - }, - "failed_partial_hours": { - "type": "number", - "format": "double" - }, - "failed_count_amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - }, - "failed_grams_amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - } - }, - "required": [ - "failed_count", - "failed_count_amount", - "failed_grams_amount", - "failed_partial_hours", - "machine_id", - "machine_name", - "machine_type", - "outcome" - ] + "description": "* `count` - Count\n* `grams` - Grams" }, - "MachineServiceMachine": { + "MemberAccountability": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" - }, - "machine_id": { - "type": "integer" - }, - "machine_name": { - "type": "string" - }, - "machine_type": { - "type": "string", - "nullable": true - }, - "request_count": { - "type": "integer" - }, - "completed_count": { - "type": "integer" - }, - "failed_count": { - "type": "integer" - }, - "completed_hours": { - "type": "number", - "format": "double" - }, - "failed_partial_hours": { - "type": "number", - "format": "double" + "membership_active": { + "type": "boolean" }, - "total_recorded_service_hours": { - "type": "number", - "format": "double" + "waiver_acceptance_required": { + "type": "boolean" }, - "failure_rate": { - "type": "number", - "format": "double", + "restriction_code": { + "type": "string", "nullable": true } }, "required": [ - "completed_count", - "completed_hours", - "failed_count", - "failed_partial_hours", - "failure_rate", - "machine_id", - "machine_name", - "machine_type", - "request_count", - "total_recorded_service_hours" + "membership_active", + "restriction_code", + "waiver_acceptance_required" ] }, - "MachineServiceReport": { + "MemberActivity": { "type": "object", "properties": { - "status_totals": { + "active_hardware_loans": { "type": "array", "items": { - "$ref": "#/components/schemas/MachineServiceStatusTotals" + "$ref": "#/components/schemas/MemberLoanActivity" } }, - "machines": { + "print_requests": { "type": "array", "items": { - "$ref": "#/components/schemas/MachineServiceMachine" + "$ref": "#/components/schemas/MemberPrintActivity" } }, - "consumption": { + "machine_service_requests": { "type": "array", "items": { - "$ref": "#/components/schemas/MachineServiceConsumption" + "$ref": "#/components/schemas/MemberMachineServiceActivity" } }, - "failure_summary": { + "bookings": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": {} + } + }, + "event_registrations": { "type": "array", "items": { - "$ref": "#/components/schemas/MachineServiceFailure" + "$ref": "#/components/schemas/MemberEventRegistrationActivity" + } + }, + "recent_presence_sessions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberPresenceActivity" + } + }, + "currently_checked_in": { + "type": "boolean" + }, + "accountability": { + "$ref": "#/components/schemas/MemberAccountability" + }, + "loan_history": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberLoanHistory" + } + }, + "request_history": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberRequestHistory" + } + }, + "membership_dues": { + "$ref": "#/components/schemas/MemberDues" + }, + "membership_term": { + "allOf": [ + { + "$ref": "#/components/schemas/MemberTerm" + } + ], + "nullable": true + }, + "notices": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberNotice" } } }, "required": [ - "consumption", - "failure_summary", - "machines", - "status_totals" + "accountability", + "active_hardware_loans", + "currently_checked_in", + "recent_presence_sessions" ] }, - "MachineServiceReportResponse": { - "oneOf": [ - { - "$ref": "#/components/schemas/MachineServiceReport" + "MemberActivityReport": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - { - "$ref": "#/components/schemas/PrinterServiceReport" + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberActivityRow" + } } + }, + "required": [ + "rows", + "typed_rows" ] }, - "MachineServiceRequest": { + "MemberActivityRow": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true + "makerspace_id": { + "type": "integer" }, - "bucket_id": { - "type": "integer", - "readOnly": true + "makerspace_name": { + "type": "string" }, - "queue_id": { + "membership_policy": { + "type": "string" + }, + "referrals_enabled": { + "type": "boolean" + }, + "new_members": { "type": "integer", - "readOnly": true + "description": "Current activated_at values in the selected [start, end) range, not event history." }, - "machine_type": { - "type": "string", - "readOnly": true + "active_members": { + "type": "integer", + "description": "Current membership snapshot." }, - "machine": { - "allOf": [ - { - "$ref": "#/components/schemas/ServiceMachine" - } - ], - "readOnly": true + "revoked_members": { + "type": "integer", + "description": "Current revoked_at values in the selected [start, end) range, not event history." }, - "assigned_machine": { - "allOf": [ - { - "$ref": "#/components/schemas/ServiceMachine" - } - ], - "readOnly": true + "pending_requests": { + "type": "integer", + "description": "Current membership-request snapshot." }, - "requester": { - "allOf": [ - { - "$ref": "#/components/schemas/ServiceRequester" - } - ], - "readOnly": true + "open_invites": { + "type": "integer", + "description": "Current membership-request snapshot." }, - "requester_name": { - "type": "string", - "readOnly": true + "referred_joins": { + "type": "integer", + "description": "Active automatic referrals with decided_at in the selected [start, end) range." }, - "contact_email": { + "verified_members": { + "type": "integer", + "description": "Current verified_at snapshot." + } + }, + "required": [ + "active_members", + "makerspace_name", + "membership_policy", + "new_members", + "open_invites", + "pending_requests", + "referrals_enabled", + "referred_joins", + "revoked_members", + "verified_members" + ] + }, + "MemberBooking": { + "type": "object", + "properties": { + "public_token": { "type": "string", + "format": "uuid", "readOnly": true }, - "contact_phone": { + "space_name": { "type": "string", "readOnly": true }, - "title": { + "timezone": { "type": "string", "readOnly": true }, - "description": { + "starts_at": { "type": "string", + "format": "date-time", "readOnly": true }, - "source_link": { + "ends_at": { "type": "string", - "format": "uri", + "format": "date-time", "readOnly": true }, "status": { "allOf": [ { - "$ref": "#/components/schemas/MachineServiceRequestStatusEnum" + "$ref": "#/components/schemas/Status2caEnum" } ], "readOnly": true }, - "reason": { + "cancellation_cutoff_at": { "type": "string", + "format": "date-time", "readOnly": true }, - "estimated_minutes": { - "type": "integer", - "readOnly": true - }, - "actual_minutes": { - "type": "integer", + "can_cancel": { + "type": "boolean", "readOnly": true }, - "fail_percent_complete": { - "type": "integer", + "confirmation_available": { + "type": "boolean", "readOnly": true + } + }, + "required": [ + "can_cancel", + "cancellation_cutoff_at", + "confirmation_available", + "ends_at", + "public_token", + "space_name", + "starts_at", + "status", + "timezone" + ] + }, + "MemberBookingList": { + "type": "object", + "properties": { + "count": { + "type": "integer" }, - "accepted_at": { + "next": { "type": "string", - "format": "date-time", - "readOnly": true, + "format": "uri", "nullable": true }, - "started_at": { + "previous": { "type": "string", - "format": "date-time", - "readOnly": true, + "format": "uri", "nullable": true }, - "completed_at": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberBooking" + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] + }, + "MemberBookingUsage": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "monthly": { + "$ref": "#/components/schemas/MemberUsageSeries" + } + }, + "required": [ + "count", + "monthly" + ] + }, + "MemberCalendarFeedIssue": { + "type": "object", + "properties": { + "confirm_bearer_risk": { + "type": "boolean" + } + }, + "required": [ + "confirm_bearer_risk" + ] + }, + "MemberCalendarFeedIssued": { + "type": "object", + "properties": { + "feed_url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "token_hint": { + "type": "string", + "readOnly": true + }, + "created_at": { "type": "string", "format": "date-time", + "readOnly": true + } + }, + "required": [ + "created_at", + "feed_url", + "token_hint" + ] + }, + "MemberCalendarFeedState": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "readOnly": true + }, + "token_hint": { + "type": "string", "readOnly": true, "nullable": true }, - "failed_at": { + "created_at": { "type": "string", "format": "date-time", "readOnly": true, "nullable": true }, - "collected_at": { + "rotated_at": { "type": "string", "format": "date-time", "readOnly": true, "nullable": true + } + }, + "required": [ + "created_at", + "enabled", + "rotated_at", + "token_hint" + ] + }, + "MemberCard": { + "type": "object", + "description": "Never carries the photo key or a URL that outlives the response.", + "properties": { + "id": { + "type": "integer", + "readOnly": true }, - "created_at": { - "type": "string", - "format": "date-time", + "card_number": { + "type": "integer", "readOnly": true }, - "updated_at": { + "printed_name": { "type": "string", - "format": "date-time", "readOnly": true }, - "capability_payload": { + "membership_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "is_active": { + "type": "boolean", "readOnly": true }, - "metering_unit": { + "photo_set": { + "type": "boolean", + "readOnly": true + }, + "photo_consent_at": { "type": "string", + "format": "date-time", "readOnly": true, "nullable": true }, - "planned_quantity": { + "qr_active": { + "type": "boolean", + "readOnly": true + }, + "print_count": { + "type": "integer", + "readOnly": true + }, + "last_printed_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "format": "date-time", "readOnly": true, "nullable": true }, - "reserved_quantity": { + "issued_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "readOnly": true, - "nullable": true + "format": "date-time", + "readOnly": true }, - "actual_consumed_quantity": { + "revoked_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "format": "date-time", "readOnly": true, "nullable": true }, - "planned_grams": { + "revoked_reason": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", "readOnly": true }, - "reserved_grams": { + "created_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "format": "date-time", "readOnly": true }, - "actual_consumed_grams": { + "updated_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "readOnly": true - }, - "payment": { - "allOf": [ - { - "$ref": "#/components/schemas/StaffPayment" - } - ], - "nullable": true, + "format": "date-time", "readOnly": true + } + }, + "required": [ + "card_number", + "created_at", + "id", + "is_active", + "issued_at", + "last_printed_at", + "membership_id", + "photo_consent_at", + "photo_set", + "print_count", + "printed_name", + "qr_active", + "revoked_at", + "revoked_reason", + "updated_at" + ] + }, + "MemberCardIssue": { + "type": "object", + "properties": { + "printed_name": { + "type": "string", + "maxLength": 200 + } + } + }, + "MemberCardPhotoFinalize": { + "type": "object", + "properties": { + "object_key": { + "type": "string", + "maxLength": 300 }, - "run_machine_model": { + "content_type": { "type": "string", - "readOnly": true + "maxLength": 64 }, - "files": { + "consent": { + "type": "boolean" + } + }, + "required": [ + "consent", + "content_type", + "object_key" + ] + }, + "MemberCardPhotoPresign": { + "type": "object", + "properties": { + "content_type": { + "type": "string", + "maxLength": 64 + } + }, + "required": [ + "content_type" + ] + }, + "MemberCardPrint": { + "type": "object", + "properties": { + "card_ids": { "type": "array", "items": { - "$ref": "#/components/schemas/ServiceFile" - }, - "readOnly": true + "type": "integer" + } }, - "consumptions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ServiceConsumption" - }, - "readOnly": true + "preset": { + "allOf": [ + { + "$ref": "#/components/schemas/PresetEnum" + } + ], + "default": "sheet" + } + } + }, + "MemberCardReissue": { + "type": "object", + "properties": { + "reason": { + "$ref": "#/components/schemas/MemberCardReissueReasonEnum" } }, "required": [ - "accepted_at", - "actual_consumed_grams", - "actual_consumed_quantity", - "actual_minutes", - "assigned_machine", - "bucket_id", - "capability_payload", - "collected_at", - "completed_at", - "consumptions", - "contact_email", - "contact_phone", - "created_at", - "description", - "estimated_minutes", - "fail_percent_complete", - "failed_at", - "files", - "id", - "machine", - "machine_type", - "metering_unit", - "payment", - "planned_grams", - "planned_quantity", - "queue_id", - "reason", - "requester", - "requester_name", - "reserved_grams", - "reserved_quantity", - "run_machine_model", - "source_link", - "started_at", - "status", - "title", - "updated_at" + "reason" ] }, - "MachineServiceRequestStatusEnum": { + "MemberCardReissueReasonEnum": { "enum": [ - "pending", - "accepted", - "in_progress", - "completed", - "collected", - "rejected", - "failed" + "lost", + "stolen", + "damaged", + "renewed" ], "type": "string", - "description": "* `pending` - Pending\n* `accepted` - Accepted\n* `in_progress` - In progress\n* `completed` - Completed\n* `collected` - Collected\n* `rejected` - Rejected\n* `failed` - Failed" + "description": "* `lost` - lost\n* `stolen` - stolen\n* `damaged` - damaged\n* `renewed` - renewed" }, - "MachineServiceStatusTotals": { + "MemberCardResolve": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" - }, - "submitted": { - "type": "integer" - }, - "accepted": { - "type": "integer" - }, - "in_progress": { - "type": "integer" - }, - "completed": { - "type": "integer" - }, - "collected": { - "type": "integer" - }, - "rejected": { - "type": "integer" - }, - "failed": { - "type": "integer" + "payload": { + "type": "string", + "maxLength": 64 } }, "required": [ - "accepted", - "collected", - "completed", - "failed", - "in_progress", - "rejected", - "submitted" + "payload" ] }, - "MachineServiceSubmit": { + "MemberCardResolveResult": { "type": "object", "properties": { - "requester_id": { - "type": "integer", - "minimum": 1 + "outcome": { + "$ref": "#/components/schemas/MemberCardResolveResultOutcomeEnum" }, - "machine_id": { - "type": "integer", - "minimum": 1 + "card_id": { + "type": "integer" }, - "title": { - "type": "string", - "maxLength": 200 + "card_number": { + "type": "integer" }, - "description": { + "printed_name": { "type": "string" }, - "source_link": { - "oneOf": [ - { - "type": "string", - "format": "uri" - }, - { - "type": "string", - "maxLength": 0 - } - ] + "membership_id": { + "type": "integer" }, - "requester_name": { + "membership_status": { "type": "string" }, - "contact_email": { - "oneOf": [ - { - "type": "string", - "format": "email" - }, - { - "type": "string", - "maxLength": 0 - } - ] - }, - "contact_phone": { + "photo_url": { "type": "string", - "maxLength": 32 - }, - "capability_payload": {} + "nullable": true + } }, "required": [ - "machine_id", - "requester_id", - "title" + "outcome" ] }, - "MachineStatusEnum": { + "MemberCardResolveResultOutcomeEnum": { "enum": [ - "idle", - "running", - "reserved", - "maintenance", - "offline" + "ok", + "revoked", + "inactive" ], "type": "string", - "description": "* `idle` - Idle\n* `running` - Running\n* `reserved` - Reserved\n* `maintenance` - Maintenance\n* `offline` - Offline" + "description": "* `ok` - ok\n* `revoked` - revoked\n* `inactive` - inactive" }, - "MachineType": { + "MemberCardRevoke": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "slug": { + "reason": { "type": "string", - "maxLength": 50, - "pattern": "^[-a-zA-Z0-9_]+$" + "maxLength": 32 + } + } + }, + "MemberCardTemplate": { + "type": "object", + "properties": { + "version": { + "type": "integer" }, - "name": { - "type": "string", - "maxLength": 200 + "page": { + "type": "string" }, - "icon": { - "type": "string", - "maxLength": 50 + "orientation": { + "type": "string" }, - "is_builtin": { + "card_width_mm": { + "type": "number", + "format": "double" + }, + "card_height_mm": { + "type": "number", + "format": "double" + }, + "margin_mm": { + "type": "number", + "format": "double" + }, + "gap_mm": { + "type": "number", + "format": "double" + }, + "front_fields": { + "type": "array", + "items": { + "type": "string" + } + }, + "back_text": { + "type": "string" + }, + "include_photo": { "type": "boolean" }, - "managing_action": { - "type": "string", - "readOnly": true + "include_qr": { + "type": "boolean" }, - "capability_config": { - "readOnly": true + "name_font_size_pt": { + "type": "integer" }, - "makerspace": { - "type": "integer", - "readOnly": true, - "nullable": true + "font_size_pt": { + "type": "integer" + }, + "crop_marks": { + "type": "boolean" } - }, - "required": [ - "capability_config", - "id", - "makerspace", - "managing_action", - "name", - "slug" - ] + } }, - "MachineTypeAccess": { + "MemberCertification": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "slug": { - "type": "string", - "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" - }, "name": { - "type": "string", - "readOnly": true + "type": "string" }, - "icon": { - "type": "string", - "readOnly": true + "machine_type_name": { + "type": "string" }, - "is_builtin": { - "type": "boolean", - "readOnly": true + "type_active": { + "type": "boolean" }, - "managing_action": { + "granted_at": { "type": "string", - "readOnly": true + "format": "date-time" }, - "capability_config": { - "readOnly": true + "expires_at": { + "type": "string", + "format": "date-time", + "nullable": true }, - "makerspace": { - "type": "integer", - "readOnly": true, + "revoked_at": { + "type": "string", + "format": "date-time", "nullable": true }, - "can_create_machine": { + "is_live": { "type": "boolean", "readOnly": true } }, "required": [ - "can_create_machine", - "capability_config", - "icon", "id", - "is_builtin", - "makerspace", - "managing_action", + "is_live", + "machine_type_name", "name", - "slug" + "type_active" ] }, - "MachineTypeCreate": { + "MemberCertificationPage": { "type": "object", "properties": { - "slug": { - "type": "string", - "maxLength": 50, - "pattern": "^[-a-zA-Z0-9_]+$" + "count": { + "type": "integer" }, - "name": { + "next": { "type": "string", - "maxLength": 200 + "nullable": true }, - "icon": { + "previous": { "type": "string", - "maxLength": 50 + "nullable": true }, - "capability_config": {} + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberCertification" + } + } }, "required": [ - "name", - "slug" + "count", + "next", + "previous", + "results" ] }, - "MachineTypeOption": { + "MemberClaimCode": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "name": { + "membership_id": { + "type": "integer", + "readOnly": true + }, + "member_display_name": { "type": "string", "readOnly": true }, - "is_active": { - "type": "boolean", + "issued_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "issued_at": { + "type": "string", + "format": "date-time", "readOnly": true }, - "is_overridden": { - "type": "boolean", + "expires_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "consumed_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "status": { + "type": "string", "readOnly": true } }, "required": [ + "consumed_at", + "expires_at", "id", - "is_active", - "is_overridden", - "name" + "issued_at", + "issued_by_id", + "member_display_name", + "membership_id", + "revoked_at", + "status" ] }, - "MachineTypePolicy": { + "MemberClaimCodeIssueRequest": { "type": "object", "properties": { - "cert_reminder_days": { - "type": "integer", - "maximum": 365, - "minimum": 0, - "nullable": true - }, - "reminder_lead_days": { - "type": "integer", - "maximum": 365, - "minimum": 0, - "nullable": true - }, - "grace_days": { + "membership_id": { "type": "integer", - "maximum": 365, - "minimum": 0, - "nullable": true + "minimum": 1 } - } + }, + "required": [ + "membership_id" + ] }, - "MachineTypePricing": { + "MemberClaimCodeIssueResponse": { "type": "object", "properties": { - "machine_type_id": { + "id": { "type": "integer", "readOnly": true }, - "rate_per_unit": { + "membership_id": { + "type": "integer", + "readOnly": true + }, + "member_display_name": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", "readOnly": true }, - "flat_fee": { + "issued_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "issued_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "format": "date-time", "readOnly": true }, - "payment_enabled": { - "type": "boolean", + "expires_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "consumed_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "status": { + "type": "string", + "readOnly": true + }, + "code": { + "type": "string", + "readOnly": true + }, + "qr_svg": { + "type": "string", "readOnly": true } }, "required": [ - "flat_fee", - "machine_type_id", - "payment_enabled", - "rate_per_unit" + "code", + "consumed_at", + "expires_at", + "id", + "issued_at", + "issued_by_id", + "member_display_name", + "membership_id", + "qr_svg", + "revoked_at", + "status" ] }, - "MachineTypePricingList": { + "MemberDues": { "type": "object", "properties": { - "currency": { - "type": "string", - "readOnly": true + "dues_amount": { + "type": "string" }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineTypePricing" + "outstanding_by_currency": { + "type": "object", + "additionalProperties": { + "type": "string" }, - "readOnly": true + "nullable": true } }, "required": [ - "currency", - "results" + "dues_amount", + "outstanding_by_currency" ] }, - "MachineTypePricingSet": { + "MemberEventRegistrationActivity": { "type": "object", "properties": { - "rate_per_unit": { + "registration_id": { + "type": "integer" + }, + "checkin_token": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "format": "uuid", + "nullable": true }, - "flat_fee": { + "event_title": { + "type": "string" + }, + "starts_at": { + "type": "string", + "format": "date-time" + }, + "ends_at": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string" + }, + "cancellation_cutoff_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "format": "date-time" }, - "payment_enabled": { + "can_cancel": { + "type": "boolean" + }, + "waitlist_position": { + "type": "integer", + "nullable": true + }, + "feedback_available": { "type": "boolean" + }, + "feedback_path": { + "type": "string", + "nullable": true + }, + "certificate": { + "type": "object", + "additionalProperties": {}, + "nullable": true } }, "required": [ - "flat_fee", - "payment_enabled", - "rate_per_unit" + "can_cancel", + "cancellation_cutoff_at", + "certificate", + "checkin_token", + "ends_at", + "event_title", + "feedback_available", + "feedback_path", + "registration_id", + "starts_at", + "status", + "waitlist_position" ] }, - "MachineUsageEntry": { + "MemberEventTicket": { "type": "object", "properties": { - "id": { + "registration_id": { "type": "integer", "readOnly": true }, - "hours": { + "status": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$", "readOnly": true }, - "source": { + "event": { "allOf": [ { - "$ref": "#/components/schemas/MachineUsageEntrySourceEnum" + "$ref": "#/components/schemas/TicketPublicFields" } ], "readOnly": true }, - "note": { - "type": "string", - "readOnly": true - }, - "logged_by_username": { - "type": "string", - "readOnly": true, - "nullable": true - }, - "created_at": { - "type": "string", - "format": "date-time", + "ticket_png_available": { + "type": "boolean", "readOnly": true } }, "required": [ - "created_at", - "hours", - "id", - "logged_by_username", - "note", - "source" - ] - }, - "MachineUsageEntrySourceEnum": { - "enum": [ - "manual", - "typed_manual" - ], - "type": "string", - "description": "* `manual` - Manual hours\n* `typed_manual` - Typed manual service" - }, - "MachineUsageReport": { - "type": "object", - "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "typed_rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineUsageRow" - } - } - }, - "required": [ - "rows", - "typed_rows" + "event", + "registration_id", + "status", + "ticket_png_available" ] }, - "MachineUsageRow": { + "MemberEventUsage": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" - }, - "machine_id": { - "type": "integer" - }, - "machine_name": { - "type": "string" - }, - "machine_type": { - "type": "string" - }, - "is_active": { - "type": "boolean" - }, - "usage_entries": { + "attended_count": { "type": "integer" }, - "usage_hours": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + "chart": { + "$ref": "#/components/schemas/MemberUsageSeries" } }, "required": [ - "is_active", - "machine_id", - "machine_name", - "machine_type", - "usage_entries", - "usage_hours" + "attended_count", + "chart" ] }, - "MaintenanceActivityReport": { + "MemberKitPreviewInput": { "type": "object", "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } + "payload": { + "type": "string", + "maxLength": 64 }, - "typed_rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MaintenanceActivityRow" - } + "phase": { + "$ref": "#/components/schemas/PhaseEnum" } }, "required": [ - "rows", - "typed_rows" + "payload", + "phase" ] }, - "MaintenanceActivityRow": { + "MemberLoanActivity": { "type": "object", "properties": { - "makerspace_id": { + "request_id": { "type": "integer" }, - "machine_id": { + "renewal_count": { "type": "integer" }, - "machine_name": { - "type": "string" - }, - "machine_type": { - "type": "string" - }, - "is_active": { + "can_renew": { "type": "boolean" }, - "log_count": { - "type": "integer" - }, - "costed_log_count": { - "type": "integer" - }, - "total_cost": { + "renewal_refusal": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + "nullable": true }, - "average_cost": { + "label": { + "type": "string" + }, + "checked_out_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", - "nullable": true + "format": "date-time" }, - "last_performed_at": { + "due_at": { "type": "string", "format": "date-time", "nullable": true }, - "average_interval_days": { - "type": "number", - "format": "double", - "nullable": true - }, - "active_schedules": { - "type": "integer" - }, - "overdue_schedules": { - "type": "integer" + "overdue": { + "type": "boolean" } }, "required": [ - "active_schedules", - "average_cost", - "average_interval_days", - "costed_log_count", - "is_active", - "last_performed_at", - "log_count", - "machine_id", - "machine_name", - "machine_type", - "overdue_schedules", - "total_cost" + "can_renew", + "checked_out_at", + "due_at", + "label", + "overdue", + "renewal_count", + "renewal_refusal", + "request_id" ] }, - "MaintenanceDocumentFinalize": { + "MemberLoanHistory": { "type": "object", "properties": { - "object_key": { + "label": { + "type": "string" + }, + "checked_out_at": { "type": "string", - "maxLength": 500 - } - }, - "required": [ - "object_key" - ] - }, - "MaintenanceDocumentPresign": { - "type": "object", - "properties": { - "filename": { + "format": "date-time" + }, + "returned_at": { "type": "string", - "maxLength": 255 + "format": "date-time", + "nullable": true }, - "content_type": { + "due_at": { "type": "string", - "maxLength": 100 + "format": "date-time", + "nullable": true + }, + "returned_late": { + "type": "boolean" } }, "required": [ - "content_type", - "filename" + "checked_out_at", + "due_at", + "label", + "returned_at", + "returned_late" ] }, - "MaintenanceDocumentPresignResponse": { + "MemberLoanUsage": { "type": "object", "properties": { - "object_key": { - "type": "string" + "count": { + "type": "integer" }, - "upload": { - "$ref": "#/components/schemas/MaintenanceDocumentUpload" + "returned_count": { + "type": "integer" + }, + "currently_out": { + "type": "integer" + }, + "monthly": { + "$ref": "#/components/schemas/MemberUsageSeries" } }, "required": [ - "object_key", - "upload" + "count", + "currently_out", + "monthly", + "returned_count" ] }, - "MaintenanceDocumentUpload": { + "MemberMachineScope": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" + "membership_id": { + "type": "integer" }, - "method": { - "type": "string" + "scoping_applies": { + "type": "boolean" }, - "fields": { - "type": "object", - "additionalProperties": {} + "machine_type_ids": { + "type": "array", + "items": { + "type": "integer" + } }, - "headers": { - "type": "object", - "additionalProperties": {} + "available_machine_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssignableMachineType" + } } }, "required": [ - "url" + "available_machine_types", + "machine_type_ids", + "membership_id", + "scoping_applies" ] }, - "MaintenanceDocumentUrl": { + "MemberMachineScopeWrite": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" + "machine_type_ids": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + } } }, "required": [ - "url" + "machine_type_ids" ] }, - "MaintenanceLog": { + "MemberMachineServiceActivity": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "machine_id": { - "type": "integer", - "readOnly": true - }, - "performed_by_id": { - "type": "integer", - "readOnly": true, - "nullable": true - }, - "performed_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "summary": { - "type": "string", - "readOnly": true + "machine_type": { + "type": "string" }, - "cost": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "readOnly": true, - "nullable": true + "title": { + "type": "string" }, - "parts_note": { - "type": "string", - "readOnly": true + "status": { + "type": "string" }, "created_at": { "type": "string", - "format": "date-time", - "readOnly": true + "format": "date-time" }, - "documents": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MaintenanceLogDocument" - }, - "readOnly": true + "queue_position": { + "type": "integer", + "nullable": true } }, "required": [ - "cost", "created_at", - "documents", - "id", - "machine_id", - "parts_note", - "performed_at", - "performed_by_id", - "summary" + "queue_position", + "status", + "title" ] }, - "MaintenanceLogDocument": { + "MemberMachineUsage": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "log_id": { - "type": "integer", - "readOnly": true - }, - "object_key": { - "type": "string", - "readOnly": true - }, - "size_bytes": { - "type": "integer", - "readOnly": true + "hours": { + "type": "string" }, - "uploaded_by_id": { - "type": "integer", - "readOnly": true, - "nullable": true + "monthly": { + "$ref": "#/components/schemas/MemberUsageSeries" }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "top_machines": { + "$ref": "#/components/schemas/MemberUsageSeries" } }, "required": [ - "created_at", - "id", - "log_id", - "object_key", - "size_bytes", - "uploaded_by_id" + "hours", + "monthly", + "top_machines" ] }, - "MaintenanceLogList": { + "MemberMessaging": { "type": "object", "properties": { - "count": { - "type": "integer" - }, - "next": { - "type": "string", - "nullable": true - }, - "previous": { - "type": "string", - "nullable": true + "sms": { + "$ref": "#/components/schemas/MessagingChannel" }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MaintenanceLog" - } + "whatsapp": { + "$ref": "#/components/schemas/MessagingChannel" } }, "required": [ - "count", - "results" + "sms", + "whatsapp" ] }, - "MaintenanceLogWrite": { + "MemberNotice": { "type": "object", + "description": "A fact about the reader, derived from their own rows.\n\nNot `notifications.Notification`: that table is makerspace-wide, has no recipient\nand shares one `read_at`, so serving it would leak staff alerts.", "properties": { - "summary": { + "level": { "type": "string" }, - "performed_at": { - "type": "string", - "format": "date-time" - }, - "cost": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "nullable": true - }, - "parts_note": { - "type": "string", - "default": "" + "event": { + "type": "string" }, - "set_idle": { - "type": "boolean", - "default": false + "title": { + "type": "string" }, - "schedule_id": { - "type": "integer", - "minimum": 1, - "writeOnly": true + "body": { + "type": "string" } }, "required": [ - "summary" + "body", + "event", + "level", + "title" ] }, - "MaintenanceSchedule": { + "MemberPayment": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "machine_id": { - "type": "integer", - "readOnly": true + "subject_type": { + "$ref": "#/components/schemas/SubjectTypeEnum" }, - "description": { + "subject_label": { "type": "string", "readOnly": true }, - "interval_days": { - "type": "integer", - "readOnly": true + "status": { + "$ref": "#/components/schemas/Status66aEnum" }, - "next_due": { + "amount": { "type": "string", - "format": "date", - "readOnly": true - }, - "anchor": { - "allOf": [ - { - "$ref": "#/components/schemas/AnchorEnum" - } - ], - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "trigger": { - "allOf": [ - { - "$ref": "#/components/schemas/MaintenanceScheduleTriggerEnum" - } - ], - "readOnly": true + "currency": { + "type": "string", + "pattern": "^[a-z]{3}$", + "maxLength": 3 }, - "usage_hours": { + "due_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "readOnly": true, + "format": "date-time", "nullable": true }, - "job_count": { - "type": "integer", - "readOnly": true, - "nullable": true + "due_date": { + "type": "string", + "nullable": true, + "readOnly": true + }, + "due_state": { + "type": "string", + "readOnly": true }, - "meter_started_at": { + "checkout_url": { "type": "string", - "format": "date-time", "readOnly": true }, - "is_active": { + "online_payment_available": { "type": "boolean", + "description": "Whether offering to pay online is honest for this charge.\n\nResolved once per makerspace by the view and passed in: doing it per row would\nre-read settings and credentials for every charge in the list. Falls back to a\nlive check only when a caller has not supplied it.", "readOnly": true }, - "created_by_id": { - "type": "integer", - "readOnly": true, - "nullable": true + "settlement": { + "allOf": [ + { + "$ref": "#/components/schemas/MemberSettlement" + } + ], + "nullable": true, + "readOnly": true }, - "created_at": { - "type": "string", - "format": "date-time", + "refunded_amount": { + "type": "number", + "format": "double", "readOnly": true }, - "updated_at": { - "type": "string", - "format": "date-time", + "refunds": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberRefund" + }, "readOnly": true }, - "overdue": { - "type": "boolean", + "created_at": { + "type": "string", + "format": "date-time", "readOnly": true } }, "required": [ - "anchor", + "amount", + "checkout_url", "created_at", - "created_by_id", - "description", + "currency", + "due_date", + "due_state", "id", - "interval_days", - "is_active", - "job_count", - "machine_id", - "meter_started_at", - "next_due", - "overdue", - "trigger", - "updated_at", - "usage_hours" + "online_payment_available", + "refunded_amount", + "refunds", + "settlement", + "subject_label", + "subject_type" ] }, - "MaintenanceScheduleList": { + "MemberPaymentUsage": { "type": "object", "properties": { - "count": { - "type": "integer" - }, - "next": { - "type": "string", - "nullable": true - }, - "previous": { - "type": "string", - "nullable": true + "paid_by_currency": { + "type": "object", + "additionalProperties": { + "type": "string" + } }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MaintenanceSchedule" + "outstanding_by_currency": { + "type": "object", + "additionalProperties": { + "type": "string" } + }, + "totals": { + "$ref": "#/components/schemas/MemberUsageSeries" } }, "required": [ - "count", - "results" + "outstanding_by_currency", + "paid_by_currency", + "totals" ] }, - "MaintenanceScheduleTriggerEnum": { - "enum": [ - "days", - "usage", - "both" - ], - "type": "string", - "description": "* `days` - Days\n* `usage` - Usage\n* `both` - Days or usage" - }, - "MaintenanceScheduleWrite": { + "MemberPresenceActivity": { "type": "object", "properties": { - "description": { - "type": "string" - }, - "interval_days": { - "type": "integer", - "minimum": 1 - }, - "next_due": { + "started_at": { "type": "string", - "format": "date" + "format": "date-time" }, - "trigger": { - "$ref": "#/components/schemas/MaintenanceScheduleWriteTriggerEnum" + "expires_at": { + "type": "string", + "format": "date-time" }, - "usage_hours": { + "ended_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "format": "date-time", "nullable": true }, - "job_count": { - "type": "integer", - "minimum": 1, - "nullable": true + "end_reason": { + "type": "string" }, - "anchor": { - "$ref": "#/components/schemas/AnchorEnum" + "active": { + "type": "boolean" } }, "required": [ - "description", - "interval_days", - "next_due" + "active", + "end_reason", + "ended_at", + "expires_at", + "started_at" ] }, - "MaintenanceScheduleWriteTriggerEnum": { - "enum": [ - "days", - "usage", - "both" - ], - "type": "string", - "description": "* `days` - days\n* `usage` - usage\n* `both` - both" - }, - "Makerspace": { + "MemberPrintActivity": { "type": "object", "properties": { - "creation_setup": { - "allOf": [ - { - "$ref": "#/components/schemas/MakerspaceCreationSetup" - } - ], - "writeOnly": true - }, - "creation_invitation": { - "allOf": [ - { - "$ref": "#/components/schemas/CreationInviteDelivery" - } - ], - "readOnly": true, - "nullable": true - }, - "install_profile_id": { - "type": "integer", - "minimum": 1, - "writeOnly": true - }, - "id": { - "type": "integer", - "readOnly": true - }, - "name": { - "type": "string", - "maxLength": 200 - }, - "public_code": { + "public_token": { "type": "string", - "pattern": "^[A-Z0-9]{4}$", - "maxLength": 4 + "format": "uuid" }, - "slug": { - "type": "string", - "maxLength": 50, - "pattern": "^[-a-zA-Z0-9_]+$" + "status": { + "type": "string" }, - "location": { - "type": "string", - "maxLength": 200 + "title": { + "type": "string" }, - "timezone": { + "created_at": { "type": "string", - "readOnly": true + "format": "date-time" }, - "country": { + "accepted_at": { "type": "string", - "readOnly": true - }, - "opening_hours": { - "oneOf": [ - { - "type": "object", - "maxProperties": 0 - }, - { - "type": "object", - "required": [ - "weekly", - "exceptions" - ], - "additionalProperties": false, - "properties": { - "weekly": { - "oneOf": [ - { - "type": "object", - "maxProperties": 0 - }, - { - "type": "object", - "additionalProperties": false, - "required": [ - "mon", - "tue", - "wed", - "thu", - "fri", - "sat", - "sun" - ], - "properties": { - "mon": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "tue": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "wed": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "thu": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "fri": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "sat": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "sun": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - } - } - } - ] - }, - "exceptions": { - "type": "object", - "maxProperties": 366, - "additionalProperties": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "description": "YYYY-MM-DD keys; each value replaces that date's weekly hours." - } - } - } - ], - "description": "Empty object means always open. Empty weekly hours mean always open except dated overrides. Empty interval lists mean closed. Sorted intervals use local wall time; split overnight hours across days.", - "readOnly": true - }, - "map_url": { - "oneOf": [ - { - "type": "string", - "format": "uri", - "maxLength": 200 - }, - { - "type": "string", - "maxLength": 0 - } - ] + "format": "date-time", + "nullable": true }, - "geofence_latitude": { + "started_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,3}(?:\\.\\d{0,6})?$", + "format": "date-time", "nullable": true }, - "geofence_longitude": { + "completed_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,3}(?:\\.\\d{0,6})?$", + "format": "date-time", "nullable": true }, - "geofence_radius_m": { - "type": "integer", - "maximum": 2147483647, - "minimum": 1 - }, - "geofence_enabled": { - "type": "boolean" - }, - "public_inventory_enabled": { - "type": "boolean" - }, - "public_site_enabled": { - "type": "boolean" - }, - "public_landing_page": { - "$ref": "#/components/schemas/PublicLandingPage6a0Enum" - }, - "public_stats_enabled": { - "type": "boolean" - }, - "public_stats_show_holder_names": { - "type": "boolean" + "estimated_minutes": { + "type": "integer" }, - "public_print_status_lookup_policy": { - "$ref": "#/components/schemas/PublicPrintStatusLookupPolicyEnum" + "queue_position": { + "type": "integer", + "nullable": true }, - "membership_policy": { - "$ref": "#/components/schemas/MembershipPolicyEnum" + "queue_approved_ahead": { + "type": "integer", + "nullable": true }, - "membership_dues_amount": { + "queue_awaiting_review_ahead": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "accepted_at", + "completed_at", + "created_at", + "estimated_minutes", + "public_token", + "queue_approved_ahead", + "queue_awaiting_review_ahead", + "queue_position", + "started_at", + "status", + "title" + ] + }, + "MemberRefund": { + "type": "object", + "description": "Money sent back on the member's own charge. Never names the staff member.", + "properties": { + "amount": { "type": "string", "format": "decimal", "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "referrals_enabled": { - "type": "boolean" + "currency": { + "type": "string" }, - "lapsed_members_cannot_borrow": { - "type": "boolean" + "status": { + "type": "string" }, - "anonymous_requests_enabled": { - "type": "boolean" + "reason": { + "type": "string" }, - "request_access": { + "settled_at": { "type": "string", - "readOnly": true + "format": "date-time", + "nullable": true }, - "filament_low_stock_threshold_grams": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$" + "method": { + "type": "string" }, - "superadmin_access_enabled": { - "type": "boolean" + "reference": { + "type": "string" }, - "archive_custody_state": { + "refunded_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "required": [ + "amount", + "currency", + "method", + "reason", + "reference", + "refunded_at", + "settled_at", + "status" + ] + }, + "MemberRegistrationCancelResponse": { + "type": "object", + "properties": { + "status": { "allOf": [ { - "$ref": "#/components/schemas/ArchiveCustodyStateEnum" + "$ref": "#/components/schemas/MemberRegistrationCancelResponseStatusEnum" } ], - "nullable": true, "readOnly": true + } + }, + "required": [ + "status" + ] + }, + "MemberRegistrationCancelResponseStatusEnum": { + "enum": [ + "cancelled" + ], + "type": "string", + "description": "* `cancelled` - cancelled" + }, + "MemberRequestHistory": { + "type": "object", + "properties": { + "status": { + "type": "string" }, - "staff_notifications_enabled": { - "type": "boolean" + "created_at": { + "type": "string", + "format": "date-time" }, - "booking_requester_notifications_enabled": { - "type": "boolean" + "item_count": { + "type": "integer" }, - "event_default_registration_audience": { - "$ref": "#/components/schemas/EventDefaultRegistrationAudienceEnum" + "returned_quantity": { + "type": "integer" }, - "event_reminder_lead_hours": { + "damaged_quantity": { + "type": "integer" + }, + "missing_quantity": { + "type": "integer" + } + }, + "required": [ + "created_at", + "damaged_quantity", + "item_count", + "missing_quantity", + "returned_quantity", + "status" + ] + }, + "MemberRetentionReport": { + "type": "object", + "properties": { + "rows": { "type": "array", "items": { - "type": "integer", - "maximum": 8760, - "minimum": 1 - }, - "maxItems": 100 + "type": "array", + "items": {} + } }, - "booking_reminder_lead_hours": { + "typed_rows": { "type": "array", "items": { - "type": "integer", - "maximum": 8760, - "minimum": 1 - }, - "maxItems": 100 + "$ref": "#/components/schemas/MemberRetentionRow" + } }, - "logo_key": { - "type": "string", - "readOnly": true + "meta": { + "type": "object", + "additionalProperties": {} }, - "logo_url": { - "type": "string", - "format": "uri", - "nullable": true, - "readOnly": true + "report_key": { + "type": "string" + } + }, + "required": [ + "meta", + "report_key", + "rows", + "typed_rows" + ] + }, + "MemberRetentionRow": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer" }, - "cover_image_key": { - "type": "string", - "readOnly": true + "cohort_month": { + "type": "string" }, - "cover_image_url": { - "type": "string", - "format": "uri", - "nullable": true, - "readOnly": true + "months_after_join": { + "type": "integer" }, - "frontend_domain": { - "type": "string", - "nullable": true, - "maxLength": 255 + "cohort_members": { + "type": "integer" }, - "frontend_domain_status": { - "allOf": [ - { - "$ref": "#/components/schemas/FrontendDomainStatusEnum" - } - ], - "readOnly": true + "eligible_members": { + "type": "integer" }, - "domain_verified_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "retained_members": { + "type": "integer" }, - "domain_verification_token": { + "retention_percent": { "type": "string", - "readOnly": true - }, - "domain_verification_record": { - "type": "object", - "nullable": true, - "properties": { - "host": { - "type": "string" - }, - "type": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "readOnly": true - }, - "platform_hosting": { - "type": "boolean", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", + "nullable": true }, - "is_platform_subdomain": { - "type": "boolean", - "readOnly": true + "churned_members": { + "type": "integer" }, - "hidden_from_central_directory": { - "type": "boolean" + "reactivated_members": { + "type": "integer" + } + }, + "required": [ + "churned_members", + "cohort_members", + "cohort_month", + "eligible_members", + "months_after_join", + "reactivated_members", + "retained_members", + "retention_percent" + ] + }, + "MemberSettlement": { + "type": "object", + "description": "The receipt for a charge staff settled in person, shown back to the payer.", + "properties": { + "method": { + "type": "string" }, - "public_api_key": { + "received_at": { "type": "string", - "readOnly": true - }, - "cors_allowed_origins": { - "readOnly": true - }, - "enabled_modules": { - "type": "array", - "items": { - "type": "string" - }, - "readOnly": true - }, - "unavailable_apps": { - "type": "array", - "items": { - "type": "string" - }, - "readOnly": true - }, - "resource_limit_overrides": {}, - "enabled_features": {}, - "theme_config": { - "type": "object", - "additionalProperties": false, - "description": "Public/member secondary colours. Foreground, hover/pressed states and light/dark link ink are derived automatically. Rejects palettes below 4.5:1 text or 3:1 component contrast in either mode (including 80% counter text and tinted surfaces). Blank colours use the product palette. Staff chrome and status colours stay fixed.", - "properties": { - "mode": { - "type": "string", - "enum": [ - "system", - "light", - "dark" - ] - }, - "primary_color": { - "type": "string", - "pattern": "^$|^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" - }, - "accent_color": { - "type": "string", - "pattern": "^$|^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" - }, - "logo_url": { - "type": "string", - "maxLength": 2048 - } - } - }, - "branding_config": { - "readOnly": true + "format": "date-time" }, - "public_display_name": { + "reference": { + "type": "string" + } + }, + "required": [ + "method", + "received_at", + "reference" + ] + }, + "MemberSignUp": { + "type": "object", + "properties": { + "display_name": { "type": "string", - "writeOnly": true, "maxLength": 200 }, - "telegram_group_chat_id": { + "email": { "type": "string", - "maxLength": 64 + "format": "email", + "maxLength": 254 }, - "telegram_bot_token": { + "phone": { "type": "string", - "writeOnly": true - }, - "telegram_bot_token_set": { - "type": "boolean", - "readOnly": true + "default": "", + "maxLength": 32 }, - "smtp_host": { + "password": { "type": "string", - "maxLength": 200 - }, - "smtp_port": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "writeOnly": true, + "maxLength": 128 }, - "smtp_username": { + "website": { "type": "string", - "maxLength": 200 + "default": "" + } + }, + "required": [ + "display_name", + "email", + "password" + ] + }, + "MemberTerm": { + "type": "object", + "properties": { + "plan_name": { + "type": "string" }, - "smtp_password": { + "starts_at": { "type": "string", - "writeOnly": true + "format": "date-time" }, - "smtp_password_set": { - "type": "boolean", - "readOnly": true + "ends_at": { + "type": "string", + "format": "date-time" }, - "smtp_use_tls": { + "current": { "type": "boolean" }, - "smtp_use_ssl": { + "blocks_borrowing": { "type": "boolean" - }, - "smtp_from_email": { - "oneOf": [ - { - "type": "string", - "format": "email", - "maxLength": 254 - }, - { - "type": "string", - "maxLength": 0 - } - ] - }, - "slack_webhook_url": { - "type": "string", - "writeOnly": true, - "maxLength": 2048 - }, - "slack_webhook_url_set": { + } + }, + "required": [ + "blocks_borrowing", + "current", + "ends_at", + "plan_name", + "starts_at" + ] + }, + "MemberUsage": { + "type": "object", + "properties": { + "usage_available": { "type": "boolean", - "readOnly": true + "description": "Active membership and membership module enabled." }, - "mattermost_webhook_url": { - "type": "string", - "writeOnly": true, - "maxLength": 2048 + "loans": { + "$ref": "#/components/schemas/MemberLoanUsage" }, - "mattermost_webhook_url_set": { - "type": "boolean", - "readOnly": true + "machines": { + "$ref": "#/components/schemas/MemberMachineUsage" }, - "discord_webhook_url": { - "type": "string", - "writeOnly": true, - "maxLength": 2048 + "bookings": { + "$ref": "#/components/schemas/MemberBookingUsage" }, - "discord_webhook_url_set": { - "type": "boolean", - "readOnly": true + "events": { + "$ref": "#/components/schemas/MemberEventUsage" }, - "default_loan_days": { + "payments": { + "$ref": "#/components/schemas/MemberPaymentUsage" + } + }, + "required": [ + "usage_available" + ] + }, + "MemberUsageSeries": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } + }, + "chart_spec": { + "$ref": "#/components/schemas/ReportChartSpec" + } + }, + "required": [ + "chart_spec", + "rows" + ] + }, + "MemberVerificationAck": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + }, + "required": [ + "detail" + ] + }, + "MemberVerificationRequest": { + "type": "object", + "properties": { + "membership_id": { "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "minimum": 1 }, - "presence_preset_minutes": {}, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "verified": { + "type": "boolean" + } + }, + "required": [ + "membership_id", + "verified" + ] + }, + "MemberVerificationResponse": { + "type": "object", + "properties": { + "membership_id": { + "type": "integer" }, - "updated_at": { + "display_name": { + "type": "string" + }, + "verified_at": { "type": "string", "format": "date-time", - "readOnly": true + "nullable": true } }, "required": [ - "archive_custody_state", - "branding_config", - "cors_allowed_origins", - "country", - "cover_image_key", - "cover_image_url", - "created_at", - "creation_invitation", - "discord_webhook_url_set", - "domain_verification_record", - "domain_verification_token", - "domain_verified_at", - "enabled_modules", - "frontend_domain_status", - "id", - "is_platform_subdomain", - "logo_key", - "logo_url", - "mattermost_webhook_url_set", - "name", - "opening_hours", - "platform_hosting", - "public_api_key", - "request_access", - "slack_webhook_url_set", - "slug", - "smtp_password_set", - "telegram_bot_token_set", - "timezone", - "unavailable_apps", - "updated_at" + "display_name", + "membership_id", + "verified_at" + ] + }, + "MemberWaiverResponse": { + "type": "object", + "properties": { + "has_waiver": { + "type": "boolean" + }, + "body": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "has_waiver" ] }, - "MakerspaceArchiveRequest": { + "MembershipAccount": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "makerspace": { - "type": "integer", - "readOnly": true - }, - "requested_by": { - "type": "integer", - "readOnly": true, - "nullable": true - }, - "requested_by_username": { - "type": "string", - "readOnly": true, - "nullable": true + "status": { + "$ref": "#/components/schemas/MembershipAccountStatusEnum" }, - "requested_at": { + "renewal_cancelled_at": { "type": "string", "format": "date-time", - "readOnly": true + "nullable": true + } + }, + "required": [ + "renewal_cancelled_at", + "status" + ] + }, + "MembershipAccountStatusEnum": { + "enum": [ + "active", + "revoked" + ], + "type": "string", + "description": "* `active` - active\n* `revoked` - revoked" + }, + "MembershipApprovalOutcome": { + "oneOf": [ + { + "$ref": "#/components/schemas/AdminMembership" }, - "resolved_by": { + { + "$ref": "#/components/schemas/MembershipRequest" + } + ] + }, + "MembershipCreate": { + "type": "object", + "properties": { + "role_id": { "type": "integer", - "readOnly": true, - "nullable": true + "description": "Single-role set; omit role_ids." }, - "resolved_by_username": { - "type": "string", - "readOnly": true, - "nullable": true + "role_ids": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Complete role set. Space Manager and Member must each be assigned alone." }, - "resolved_at": { + "username": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "maxLength": 150 }, - "reason": { + "email": { + "oneOf": [ + { + "type": "string", + "format": "email", + "maxLength": 254 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "first_name": { "type": "string", - "description": "Do not include personal data. Maximum 2,000 characters.", - "maxLength": 2000 + "maxLength": 150 }, - "resolution_note": { + "last_name": { "type": "string", - "readOnly": true + "maxLength": 150 }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/MakerspaceArchiveRequestStatusEnum" - } - ], - "readOnly": true, - "default": "pending" + "password": { + "type": "string", + "writeOnly": true } }, "required": [ - "id", - "makerspace", - "reason", - "requested_at", - "requested_by", - "requested_by_username", - "resolution_note", - "resolved_at", - "resolved_by", - "resolved_by_username", - "status" + "username" ] }, - "MakerspaceArchiveRequestStatusEnum": { + "MembershipDispositionEnum": { "enum": [ - "pending", - "approved", - "declined", - "withdrawn" + "import_membership", + "no_membership" ], "type": "string", - "description": "* `pending` - Pending\n* `approved` - Approved\n* `declined` - Declined\n* `withdrawn` - Withdrawn" + "description": "* `import_membership` - Import membership\n* `no_membership` - Do not import membership" }, - "MakerspaceCreationSetup": { + "MembershipGrowthReport": { "type": "object", "properties": { - "profile": { - "$ref": "#/components/schemas/CreationProfile" - }, - "modules": { + "rows": { "type": "array", "items": { - "$ref": "#/components/schemas/ModulesEnum" - }, - "maxItems": 60 + "type": "array", + "items": {} + } }, - "first_staff_invite": { - "$ref": "#/components/schemas/FirstStaffInvite" + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MembershipGrowthRow" + } } - } + }, + "required": [ + "rows", + "typed_rows" + ] }, - "MakerspacePaymentSettings": { + "MembershipGrowthRow": { "type": "object", "properties": { - "default_currency": { - "type": "string", - "maxLength": 3 + "makerspace_id": { + "type": "integer" }, - "stripe_publishable_key": { + "period": { "type": "string", - "writeOnly": true, - "maxLength": 255 - }, - "stripe_publishable_key_set": { - "type": "boolean", - "readOnly": true + "format": "date" }, - "stripe_secret_key": { - "type": "string", - "writeOnly": true + "new_members": { + "type": "integer" }, - "stripe_secret_key_set": { - "type": "boolean", - "readOnly": true + "activated_members": { + "type": "integer" }, - "stripe_webhook_secret": { - "type": "string", - "writeOnly": true + "revoked_members": { + "type": "integer" }, - "stripe_webhook_secret_set": { - "type": "boolean", + "active_members": { + "type": "integer" + } + }, + "required": [ + "activated_members", + "active_members", + "new_members", + "period", + "revoked_members" + ] + }, + "MembershipList": { + "type": "object", + "properties": { + "id": { + "type": "integer", "readOnly": true }, - "connect_account_id": { - "type": "string", - "readOnly": true, - "nullable": true - }, - "connect_status": { + "user": { "allOf": [ { - "$ref": "#/components/schemas/ConnectStatusEnum" + "$ref": "#/components/schemas/User" } ], "readOnly": true }, - "connect_charges_enabled": { - "type": "boolean", - "readOnly": true - }, - "connect_payouts_enabled": { - "type": "boolean", - "readOnly": true - }, - "connect_status_updated_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "effective_mode": { - "type": "string", - "readOnly": true - }, - "loan_deposit_mode": { - "$ref": "#/components/schemas/LoanDepositModeEnum" - }, - "loan_deposit_amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - }, - "loan_late_fee_per_day": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - }, - "loan_late_fee_cap": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - }, - "loan_grace_days": { + "makerspace_id": { "type": "integer", - "maximum": 32767, - "minimum": 0 - }, - "loan_deposit_blocks_issue": { - "type": "boolean" - } - }, - "required": [ - "connect_account_id", - "connect_charges_enabled", - "connect_payouts_enabled", - "connect_status", - "connect_status_updated_at", - "effective_mode", - "stripe_publishable_key_set", - "stripe_secret_key_set", - "stripe_webhook_secret_set" - ] - }, - "MakerspaceProfile": { - "type": "object", - "properties": { - "timezone": { - "type": "string", - "maxLength": 100 + "readOnly": true }, - "country": { + "makerspace_slug": { "type": "string", - "maxLength": 2 + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "opening_hours": { - "oneOf": [ - { - "type": "object", - "maxProperties": 0 - }, + "role": { + "allOf": [ { - "type": "object", - "required": [ - "weekly", - "exceptions" - ], - "additionalProperties": false, - "properties": { - "weekly": { - "oneOf": [ - { - "type": "object", - "maxProperties": 0 - }, - { - "type": "object", - "additionalProperties": false, - "required": [ - "mon", - "tue", - "wed", - "thu", - "fri", - "sat", - "sun" - ], - "properties": { - "mon": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "tue": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "wed": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "thu": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "fri": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "sat": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "sun": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - } - } - } - ] - }, - "exceptions": { - "type": "object", - "maxProperties": 366, - "additionalProperties": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "description": "YYYY-MM-DD keys; each value replaces that date's weekly hours." - } - } + "$ref": "#/components/schemas/RoleCddEnum" } ], - "description": "Empty object means always open. Empty weekly hours mean always open except dated overrides. Empty interval lists mean closed. Sorted intervals use local wall time; split overnight hours across days." + "readOnly": true }, - "default_currency": { - "type": "string", - "maxLength": 3 + "assigned_role": { + "allOf": [ + { + "$ref": "#/components/schemas/MembershipRoleSummary" + } + ], + "readOnly": true, + "nullable": true }, - "timezone_choices": { + "role_ids": { "type": "array", "items": { - "type": "string" + "type": "integer" }, "readOnly": true }, - "country_choices": { + "roles": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/MembershipRole" }, "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/Status37fEnum" + } + ], + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "payment": { + "allOf": [ + { + "$ref": "#/components/schemas/StaffPaymentSummary" + } + ], + "nullable": true, + "readOnly": true } }, "required": [ - "country", - "country_choices", - "default_currency", - "opening_hours", - "timezone", - "timezone_choices" + "assigned_role", + "created_at", + "id", + "makerspace_id", + "makerspace_slug", + "payment", + "role", + "role_ids", + "roles", + "status", + "user" ] }, - "MakerspaceUsage": { + "MembershipListResponse": { "type": "object", "properties": { - "makerspace_id": { + "count": { "type": "integer" }, - "managed": { - "type": "boolean" + "next": { + "type": "string", + "format": "uri", + "nullable": true }, - "resources": { + "previous": { + "type": "string", + "format": "uri", + "nullable": true + }, + "results": { "type": "array", "items": { - "$ref": "#/components/schemas/UsageResource" + "$ref": "#/components/schemas/AdminMembership" } } }, "required": [ - "makerspace_id", - "managed", - "resources" + "count", + "next", + "previous", + "results" ] }, - "ManagedPolicyMarker": { + "MembershipOutcome": { "type": "object", "properties": { - "feature": { - "type": "string" + "outcome": { + "$ref": "#/components/schemas/MembershipOutcomeOutcomeEnum" }, - "event": { - "type": "string" + "membership_id": { + "type": "integer" }, - "count": { - "type": "integer", - "minimum": 1 + "request_id": { + "type": "integer" + }, + "state": { + "type": "string" } }, "required": [ - "count", - "event", - "feature" + "outcome", + "state" ] }, - "ManualRefundError": { - "oneOf": [ - { - "$ref": "#/components/schemas/HardwareRequestError" - }, - { - "$ref": "#/components/schemas/ManualRefundValidationError" - } - ] + "MembershipOutcomeOutcomeEnum": { + "enum": [ + "joined", + "requested" + ], + "type": "string", + "description": "* `joined` - joined\n* `requested` - requested" }, - "ManualRefundReceipt": { + "MembershipPlan": { "type": "object", "properties": { - "method": { - "$ref": "#/components/schemas/Method2edEnum" + "id": { + "type": "integer", + "readOnly": true }, - "reference": { + "name": { "type": "string", - "default": "", - "maxLength": 64 + "maxLength": 120 }, - "refunded_at": { + "interval": { + "$ref": "#/components/schemas/IntervalEnum" + }, + "custom_days": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0, + "nullable": true + }, + "amount": { "type": "string", - "format": "date-time" + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "currency": { + "type": "string", + "maxLength": 3 + }, + "is_active": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "loan_max_active": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0, + "nullable": true + }, + "loan_max_items": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0, + "nullable": true + }, + "auto_renew": { + "type": "boolean" + }, + "renewal_open_days": { + "type": "integer", + "maximum": 365, + "minimum": 0 + }, + "renewal_grace_days": { + "type": "integer", + "maximum": 365, + "minimum": 0 + }, + "renewal_retries": { + "type": "integer", + "maximum": 3, + "minimum": 0 + }, + "reminder_days_before": {}, + "reminder_days_after": {} + }, + "required": [ + "created_at", + "id", + "interval", + "name", + "updated_at" + ] + }, + "MembershipPolicyEnum": { + "enum": [ + "request", + "open", + "invite_only" + ], + "type": "string", + "description": "* `request` - Request\n* `open` - Open\n* `invite_only` - Invite only" + }, + "MembershipReason": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "maxLength": 1000 } }, "required": [ - "method", - "refunded_at" + "reason" ] }, - "ManualRefundValidationError": { + "MembershipRequest": { "type": "object", "properties": { - "amount": { - "type": "array", - "items": { - "type": "string" - } + "id": { + "type": "integer", + "readOnly": true }, - "reason": { - "type": "array", - "items": { - "type": "string" - } + "kind": { + "$ref": "#/components/schemas/MembershipRequestKindEnum" }, - "receipt": {}, - "non_field_errors": { + "state": { + "$ref": "#/components/schemas/State9efEnum" + }, + "invite_email": { + "type": "string", + "maxLength": 254 + }, + "user": { + "allOf": [ + { + "$ref": "#/components/schemas/MembershipRequestUser" + } + ], + "nullable": true, + "readOnly": true + }, + "assigned_role": { + "allOf": [ + { + "$ref": "#/components/schemas/MembershipRequestRole" + } + ], + "nullable": true, + "readOnly": true + }, + "decision_note": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "decided_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "application_answers": { + "nullable": true, + "readOnly": true + }, + "vouches": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/Vouch" } + }, + "admission_payment": { + "allOf": [ + { + "$ref": "#/components/schemas/AdmissionPayment" + } + ], + "nullable": true, + "readOnly": true + }, + "expires_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "required": [ + "admission_payment", + "application_answers", + "assigned_role", + "created_at", + "id", + "kind", + "state", + "user", + "vouches" + ] + }, + "MembershipRequestCreate": { + "type": "object", + "properties": { + "application_answers": { + "nullable": true + }, + "website": { + "type": "string", + "writeOnly": true } } }, - "ManualSettlement": { + "MembershipRequestKindEnum": { + "enum": [ + "request", + "invite" + ], + "type": "string", + "description": "* `request` - Request\n* `invite` - Invite" + }, + "MembershipRequestListResponse": { "type": "object", - "description": "The receipt required to mark a charge paid offline.\n\nMethod and received date are REQUIRED: the point of the ledger is that a settled\ncharge can always say how and when the money arrived, and an optional field would\nquietly reproduce the bare boolean it replaces. `reference` stays optional because\ncash genuinely has none.", "properties": { - "method": { - "$ref": "#/components/schemas/Method2edEnum" + "count": { + "type": "integer" }, - "reference": { + "next": { "type": "string", - "default": "", - "maxLength": 64 + "format": "uri", + "nullable": true }, - "received_at": { + "previous": { "type": "string", - "format": "date-time" + "format": "uri", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MembershipRequest" + } } }, "required": [ - "method", - "received_at" + "count", + "next", + "previous", + "results" ] }, - "Material": { + "MembershipRequestRole": { "type": "object", "properties": { "id": { - "type": "integer", - "readOnly": true - }, - "machine_type": { "type": "integer" }, "name": { - "type": "string", - "maxLength": 100 - }, - "colours": {}, - "is_active": { - "type": "boolean" + "type": "string" } }, "required": [ - "colours", "id", - "machine_type", "name" ] }, - "Measurement883Enum": { - "enum": [ - "count", - "grams" - ], - "type": "string", - "description": "* `count` - Count\n* `grams` - Grams" - }, - "MemberAccountability": { + "MembershipRequestUser": { "type": "object", "properties": { - "membership_active": { - "type": "boolean" + "id": { + "type": "integer" }, - "waiver_acceptance_required": { - "type": "boolean" + "username": { + "type": "string" }, - "restriction_code": { + "email": { "type": "string", - "nullable": true + "format": "email" } }, "required": [ - "membership_active", - "restriction_code", - "waiver_acceptance_required" + "email", + "id", + "username" ] }, - "MemberActivity": { + "MembershipRevenueReport": { "type": "object", "properties": { - "active_hardware_loans": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberLoanActivity" - } - }, - "print_requests": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberPrintActivity" - } - }, - "machine_service_requests": { + "rows": { "type": "array", "items": { - "$ref": "#/components/schemas/MemberMachineServiceActivity" - } - }, - "bookings": { - "type": "object", - "additionalProperties": { "type": "array", "items": {} } }, - "event_registrations": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberEventRegistrationActivity" - } - }, - "recent_presence_sessions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberPresenceActivity" - } - }, - "currently_checked_in": { - "type": "boolean" - }, - "accountability": { - "$ref": "#/components/schemas/MemberAccountability" - }, - "loan_history": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberLoanHistory" - } - }, - "request_history": { + "typed_rows": { "type": "array", "items": { - "$ref": "#/components/schemas/MemberRequestHistory" + "$ref": "#/components/schemas/MembershipRevenueRow" } }, - "membership_dues": { - "$ref": "#/components/schemas/MemberDues" - }, - "membership_term": { - "allOf": [ - { - "$ref": "#/components/schemas/MemberTerm" - } - ], - "nullable": true + "meta": { + "type": "object", + "additionalProperties": {} }, - "notices": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberNotice" - } + "report_key": { + "type": "string" } }, "required": [ - "accountability", - "active_hardware_loans", - "currently_checked_in", - "recent_presence_sessions" + "meta", + "report_key", + "rows", + "typed_rows" ] }, - "MemberActivityReport": { + "MembershipRevenueRow": { "type": "object", "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } + "makerspace_id": { + "type": "integer" + }, + "period": { + "type": "string", + "format": "date" + }, + "currency": { + "type": "string" + }, + "plan_id": { + "type": "integer" + }, + "plan_name": { + "type": "string" + }, + "recurring_members": { + "type": "integer" + }, + "mrr": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$" + }, + "arr": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$" + }, + "arpu": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$" }, - "typed_rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberActivityRow" - } + "churned_members": { + "type": "integer" + }, + "reactivated_members": { + "type": "integer" } }, "required": [ - "rows", - "typed_rows" + "arpu", + "arr", + "churned_members", + "currency", + "mrr", + "period", + "plan_id", + "plan_name", + "reactivated_members", + "recurring_members" ] }, - "MemberActivityRow": { + "MembershipRole": { "type": "object", "properties": { - "makerspace_id": { + "id": { "type": "integer" }, - "makerspace_name": { + "slug": { "type": "string" }, - "membership_policy": { + "name": { "type": "string" - }, - "referrals_enabled": { - "type": "boolean" - }, - "new_members": { - "type": "integer", - "description": "Current activated_at values in the selected [start, end) range, not event history." - }, - "active_members": { + } + }, + "required": [ + "id", + "name", + "slug" + ] + }, + "MembershipRoleSummary": { + "type": "object", + "properties": { + "id": { "type": "integer", - "description": "Current membership snapshot." + "readOnly": true }, - "revoked_members": { - "type": "integer", - "description": "Current revoked_at values in the selected [start, end) range, not event history." + "name": { + "type": "string", + "readOnly": true }, - "pending_requests": { - "type": "integer", - "description": "Current membership-request snapshot." + "slug": { + "type": "string", + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "open_invites": { - "type": "integer", - "description": "Current membership-request snapshot." + "legacy_role": { + "readOnly": true, + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/LegacyRoleEnum" + }, + { + "$ref": "#/components/schemas/NullEnum" + } + ] }, - "referred_joins": { - "type": "integer", - "description": "Active automatic referrals with decided_at in the selected [start, end) range." + "is_default": { + "type": "boolean", + "readOnly": true }, - "verified_members": { - "type": "integer", - "description": "Current verified_at snapshot." + "is_protected": { + "type": "boolean", + "readOnly": true } }, "required": [ - "active_members", - "makerspace_name", - "membership_policy", - "new_members", - "open_invites", - "pending_requests", - "referrals_enabled", - "referred_joins", - "revoked_members", - "verified_members" + "id", + "is_default", + "is_protected", + "legacy_role", + "name", + "slug" ] }, - "MemberBooking": { + "MembershipTerm": { "type": "object", "properties": { - "public_token": { - "type": "string", - "format": "uuid", + "id": { + "type": "integer", "readOnly": true }, - "space_name": { - "type": "string", + "membership": { + "type": "integer", "readOnly": true }, - "timezone": { + "plan": { + "type": "integer", + "readOnly": true + }, + "plan_name": { "type": "string", "readOnly": true }, @@ -84117,925 +106343,993 @@ "status": { "allOf": [ { - "$ref": "#/components/schemas/Status2caEnum" + "$ref": "#/components/schemas/MembershipTermStatusEnum" } ], "readOnly": true }, - "cancellation_cutoff_at": { + "renewal_payment": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_by": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { "type": "string", "format": "date-time", "readOnly": true - }, - "can_cancel": { - "type": "boolean", - "readOnly": true } }, "required": [ - "can_cancel", - "cancellation_cutoff_at", + "created_at", + "created_by", "ends_at", - "public_token", - "space_name", + "id", + "membership", + "plan", + "plan_name", + "renewal_payment", "starts_at", - "status", - "timezone" + "status" ] }, - "MemberBookingList": { + "MembershipTermCreate": { "type": "object", "properties": { - "count": { + "plan_id": { "type": "integer" }, - "next": { - "type": "string", - "format": "uri", - "nullable": true - }, - "previous": { + "starts_at": { "type": "string", - "format": "uri", + "format": "date-time", "nullable": true - }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberBooking" - } } }, "required": [ - "count", - "next", - "previous", - "results" + "plan_id" ] }, - "MemberCalendarFeedIssue": { - "type": "object", - "properties": { - "confirm_bearer_risk": { - "type": "boolean" - } - }, - "required": [ - "confirm_bearer_risk" - ] + "MembershipTermStatusEnum": { + "enum": [ + "active", + "expired", + "cancelled" + ], + "type": "string", + "description": "* `active` - Active\n* `expired` - Expired\n* `cancelled` - Cancelled" }, - "MemberCalendarFeedIssued": { + "MessagingCatalogEntry": { "type": "object", "properties": { - "feed_url": { - "type": "string", - "format": "uri", - "readOnly": true + "feature": { + "type": "string" }, - "token_hint": { - "type": "string", - "readOnly": true + "event": { + "type": "string" }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "audience": { + "$ref": "#/components/schemas/Audience817Enum" + }, + "allowed_projection_keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "supported_languages": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ - "created_at", - "feed_url", - "token_hint" + "allowed_projection_keys", + "audience", + "event", + "feature", + "supported_languages" ] }, - "MemberCalendarFeedState": { + "MessagingChannel": { "type": "object", "properties": { - "enabled": { - "type": "boolean", - "readOnly": true + "available": { + "type": "boolean" }, - "token_hint": { - "type": "string", - "readOnly": true, - "nullable": true + "consented": { + "type": "boolean" }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "phone_verified": { + "type": "boolean" }, - "rotated_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "phone_masked": { + "type": "string" + }, + "suppressed": { + "type": "boolean" } }, "required": [ - "created_at", - "enabled", - "rotated_at", - "token_hint" + "available", + "consented", + "phone_masked", + "phone_verified", + "suppressed" ] }, - "MemberCard": { + "MessagingSettings": { "type": "object", - "description": "Never carries the photo key or a URL that outlives the response.", "properties": { - "id": { - "type": "integer", - "readOnly": true + "use_platform_sender": { + "type": "boolean" }, - "card_number": { - "type": "integer", - "readOnly": true + "sms_account_sid": { + "oneOf": [ + { + "type": "string", + "pattern": "^(AC[0-9a-fA-F]{32})?$" + }, + { + "type": "string", + "maxLength": 0 + } + ] }, - "printed_name": { + "sms_from_number": { "type": "string", - "readOnly": true + "maxLength": 32 }, - "membership_id": { - "type": "integer", - "readOnly": true, - "nullable": true + "sms_auth_token": { + "type": "string", + "writeOnly": true, + "maxLength": 4096 }, - "is_active": { - "type": "boolean", - "readOnly": true + "whatsapp_phone_number_id": { + "oneOf": [ + { + "type": "string", + "pattern": "^\\d{1,64}$" + }, + { + "type": "string", + "maxLength": 0 + } + ] }, - "photo_set": { - "type": "boolean", - "readOnly": true + "whatsapp_business_account_id": { + "oneOf": [ + { + "type": "string", + "pattern": "^\\d{1,64}$" + }, + { + "type": "string", + "maxLength": 0 + } + ] }, - "photo_consent_at": { + "whatsapp_access_token": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "writeOnly": true, + "maxLength": 8192 }, - "qr_active": { + "whatsapp_app_secret": { + "type": "string", + "writeOnly": true, + "maxLength": 4096 + }, + "sms_auth_token_set": { "type": "boolean", "readOnly": true }, - "print_count": { - "type": "integer", + "whatsapp_access_token_set": { + "type": "boolean", "readOnly": true }, - "last_printed_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "issued_at": { - "type": "string", - "format": "date-time", + "whatsapp_app_secret_set": { + "type": "boolean", "readOnly": true }, - "revoked_at": { + "whatsapp_webhook_verify_token": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "readOnly": true }, - "revoked_reason": { - "type": "string", + "effective_source": { + "type": "object", + "additionalProperties": { + "enum": [ + "own", + "platform", + "none" + ], + "type": "string", + "description": "* `own` - own\n* `platform` - platform\n* `none` - none" + }, "readOnly": true }, - "created_at": { - "type": "string", - "format": "date-time", + "platform_available": { + "type": "object", + "additionalProperties": { + "type": "boolean" + }, "readOnly": true }, - "updated_at": { - "type": "string", - "format": "date-time", + "webhook_urls": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "readOnly": true } }, "required": [ - "card_number", - "created_at", - "id", - "is_active", - "issued_at", - "last_printed_at", - "membership_id", - "photo_consent_at", - "photo_set", - "print_count", - "printed_name", - "qr_active", - "revoked_at", - "revoked_reason", - "updated_at" + "effective_source", + "platform_available", + "sms_auth_token_set", + "webhook_urls", + "whatsapp_access_token_set", + "whatsapp_app_secret_set", + "whatsapp_webhook_verify_token" ] }, - "MemberCardIssue": { + "MessagingTest": { "type": "object", "properties": { - "printed_name": { + "channel": { + "$ref": "#/components/schemas/Channel06eEnum" + }, + "feature": { "type": "string", - "maxLength": 200 + "default": "hardware_requests" + }, + "event": { + "type": "string", + "default": "accepted" } - } + }, + "required": [ + "channel" + ] }, - "MemberCardPhotoFinalize": { + "MessagingTestResponse": { "type": "object", "properties": { - "object_key": { - "type": "string", - "maxLength": 300 + "delivery_id": { + "type": "integer" }, - "content_type": { - "type": "string", - "maxLength": 64 + "status": { + "type": "string" }, - "consent": { - "type": "boolean" + "skip_reason": { + "type": "string" } }, "required": [ - "consent", - "content_type", - "object_key" + "delivery_id", + "skip_reason", + "status" ] }, - "MemberCardPhotoPresign": { + "Method2edEnum": { + "enum": [ + "cash", + "upi", + "bank_transfer", + "card_machine", + "cheque", + "other" + ], + "type": "string", + "description": "* `cash` - Cash\n* `upi` - UPI\n* `bank_transfer` - Bank transfer\n* `card_machine` - Card machine\n* `cheque` - Cheque\n* `other` - Other" + }, + "MigrationExportCreate": { "type": "object", "properties": { - "content_type": { + "approval_id": { "type": "string", - "maxLength": 64 + "format": "uuid" + }, + "target_age_recipient": { + "type": "string", + "writeOnly": true, + "maxLength": 256 } }, "required": [ - "content_type" + "approval_id", + "target_age_recipient" ] }, - "MemberCardPrint": { + "MigrationExportJob": { "type": "object", "properties": { - "card_ids": { - "type": "array", - "items": { - "type": "integer" - } + "id": { + "type": "string", + "format": "uuid", + "readOnly": true + }, + "status": { + "$ref": "#/components/schemas/StatusE1dEnum" + }, + "failure_code": { + "oneOf": [ + { + "$ref": "#/components/schemas/FailureCodeEnum" + }, + { + "$ref": "#/components/schemas/BlankEnum" + } + ] + }, + "failure_detail": { + "type": "string", + "maxLength": 500 + }, + "manifest": {}, + "closure_digest": { + "type": "string" + }, + "archive_digest": { + "type": "string" + }, + "format_version": { + "type": "integer" + }, + "source_retention_notice": { + "type": "string", + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "started_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "completed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "expires_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "archive_digest", + "closure_digest", + "created_at", + "expires_at", + "format_version", + "id", + "source_retention_notice" + ] + }, + "MobilePaymentIntentResponse": { + "oneOf": [ + { + "$ref": "#/components/schemas/StripeMobilePaymentIntentResponse" }, - "preset": { - "allOf": [ - { - "$ref": "#/components/schemas/PresetEnum" - } - ], - "default": "sheet" + { + "$ref": "#/components/schemas/RazorpayMobilePaymentIntentResponse" + } + ], + "discriminator": { + "propertyName": "vendor", + "mapping": { + "stripe": "#/components/schemas/StripeMobilePaymentIntentResponse", + "razorpay": "#/components/schemas/RazorpayMobilePaymentIntentResponse" } } }, - "MemberCardReissue": { + "MobileReadiness": { "type": "object", "properties": { - "reason": { - "$ref": "#/components/schemas/MemberCardReissueReasonEnum" + "platforms": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/PlatformReadiness" + } } }, "required": [ - "reason" + "platforms" ] }, - "MemberCardReissueReasonEnum": { + "Mode087Enum": { "enum": [ - "lost", - "stolen", - "damaged", - "renewed" + "preview", + "apply" ], "type": "string", - "description": "* `lost` - lost\n* `stolen` - stolen\n* `damaged` - damaged\n* `renewed` - renewed" - }, - "MemberCardResolve": { - "type": "object", - "properties": { - "payload": { - "type": "string", - "maxLength": 64 - } - }, - "required": [ - "payload" - ] + "description": "* `preview` - Preview\n* `apply` - Apply" }, - "MemberCardResolveResult": { + "ModuleAction": { "type": "object", + "description": "The one field either mutation takes.\n\nA real class rather than `inline_serializer`, which returns an *instance* and so\ncannot be called with `data=` at request time.", "properties": { - "outcome": { - "$ref": "#/components/schemas/MemberCardResolveResultOutcomeEnum" - }, - "card_id": { - "type": "integer" - }, - "card_number": { - "type": "integer" - }, - "printed_name": { - "type": "string" - }, - "membership_id": { - "type": "integer" - }, - "membership_status": { + "key": { "type": "string" - }, - "photo_url": { - "type": "string", - "nullable": true } }, "required": [ - "outcome" + "key" ] }, - "MemberCardResolveResultOutcomeEnum": { + "ModulesEnum": { "enum": [ - "ok", - "revoked", - "inactive" + "asset_units", + "backup", + "bookings", + "bulk_import", + "containers", + "devices", + "discord", + "email", + "events", + "evidence_uploads", + "guest_handover", + "machine_service", + "machines", + "maintenance", + "mattermost", + "member_accounts", + "membership", + "mobile", + "notifications", + "payments", + "printing", + "procurement", + "public_inventory", + "qr_management", + "qr_print_batches", + "reports", + "request_workflow", + "scanner", + "slack", + "sms", + "staff_admin", + "stock_transfers", + "stocktake", + "telegram", + "webhook", + "whatsapp" ], "type": "string", - "description": "* `ok` - ok\n* `revoked` - revoked\n* `inactive` - inactive" - }, - "MemberCardRevoke": { - "type": "object", - "properties": { - "reason": { - "type": "string", - "maxLength": 32 - } - } + "description": "* `asset_units` - asset_units\n* `backup` - backup\n* `bookings` - bookings\n* `bulk_import` - bulk_import\n* `containers` - containers\n* `devices` - devices\n* `discord` - discord\n* `email` - email\n* `events` - events\n* `evidence_uploads` - evidence_uploads\n* `guest_handover` - guest_handover\n* `machine_service` - machine_service\n* `machines` - machines\n* `maintenance` - maintenance\n* `mattermost` - mattermost\n* `member_accounts` - member_accounts\n* `membership` - membership\n* `mobile` - mobile\n* `notifications` - notifications\n* `payments` - payments\n* `printing` - printing\n* `procurement` - procurement\n* `public_inventory` - public_inventory\n* `qr_management` - qr_management\n* `qr_print_batches` - qr_print_batches\n* `reports` - reports\n* `request_workflow` - request_workflow\n* `scanner` - scanner\n* `slack` - slack\n* `sms` - sms\n* `staff_admin` - staff_admin\n* `stock_transfers` - stock_transfers\n* `stocktake` - stocktake\n* `telegram` - telegram\n* `webhook` - webhook\n* `whatsapp` - whatsapp" }, - "MemberCardTemplate": { + "MostLentReport": { "type": "object", "properties": { - "version": { - "type": "integer" - }, - "page": { - "type": "string" - }, - "orientation": { - "type": "string" - }, - "card_width_mm": { - "type": "number", - "format": "double" - }, - "card_height_mm": { - "type": "number", - "format": "double" - }, - "margin_mm": { - "type": "number", - "format": "double" - }, - "gap_mm": { - "type": "number", - "format": "double" - }, - "front_fields": { + "rows": { "type": "array", "items": { - "type": "string" + "type": "array", + "items": {} } }, - "back_text": { - "type": "string" - }, - "include_photo": { - "type": "boolean" - }, - "include_qr": { - "type": "boolean" - }, - "name_font_size_pt": { - "type": "integer" - }, - "font_size_pt": { - "type": "integer" - }, - "crop_marks": { - "type": "boolean" - } - } - }, - "MemberCertification": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "name": { - "type": "string" - }, - "machine_type_name": { - "type": "string" - }, - "type_active": { - "type": "boolean" - }, - "granted_at": { - "type": "string", - "format": "date-time" - }, - "expires_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "revoked_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "is_live": { - "type": "boolean", - "readOnly": true + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MostLentReportRow" + } } }, "required": [ - "id", - "is_live", - "machine_type_name", - "name", - "type_active" + "rows", + "typed_rows" ] }, - "MemberCertificationPage": { + "MostLentReportRow": { "type": "object", "properties": { - "count": { + "makerspace_id": { "type": "integer" }, - "next": { + "period": { "type": "string", - "nullable": true + "format": "date", + "nullable": true, + "description": "Null for legacy loans without an issue timestamp." }, - "previous": { - "type": "string", - "nullable": true + "product_id": { + "type": "integer", + "nullable": true, + "description": "Null identifies the Other series." }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberCertification" - } + "product_name": { + "type": "string" + }, + "times_lent": { + "type": "integer" + }, + "total_quantity_lent": { + "type": "integer" } }, "required": [ - "count", - "next", - "previous", - "results" + "period", + "product_id", + "product_name", + "times_lent", + "total_quantity_lent" ] }, - "MemberClaimCode": { + "MoveToInventoryRequest": { "type": "object", "properties": { - "id": { + "mode": { + "$ref": "#/components/schemas/MoveToInventoryRequestModeEnum" + }, + "product_id": { "type": "integer", - "readOnly": true + "nullable": true }, - "membership_id": { + "quantity": { "type": "integer", - "readOnly": true + "minimum": 1 }, - "member_display_name": { - "type": "string", - "readOnly": true + "box": { + "type": "integer", + "nullable": true }, - "issued_by_id": { + "category": { "type": "integer", - "readOnly": true, "nullable": true }, - "issued_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "tracking_mode": { + "allOf": [ + { + "$ref": "#/components/schemas/MoveToInventoryRequestTrackingModeEnum" + } + ], + "default": "quantity" }, - "expires_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "is_public": { + "type": "boolean", + "default": true }, - "consumed_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "public_availability_mode": { + "allOf": [ + { + "$ref": "#/components/schemas/MoveToInventoryRequestPublicAvailabilityModeEnum" + } + ], + "default": "status_only" }, - "revoked_at": { + "show_public_count": { + "type": "boolean", + "default": false + }, + "public_self_checkout_enabled": { + "type": "boolean", + "default": false + }, + "name": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "maxLength": 200 }, - "status": { + "description": { "type": "string", - "readOnly": true + "default": "" } }, "required": [ - "consumed_at", - "expires_at", - "id", - "issued_at", - "issued_by_id", - "member_display_name", - "membership_id", - "revoked_at", - "status" + "mode", + "quantity" ] }, - "MemberClaimCodeIssueRequest": { - "type": "object", - "properties": { - "membership_id": { - "type": "integer", - "minimum": 1 - } - }, - "required": [ - "membership_id" - ] + "MoveToInventoryRequestModeEnum": { + "enum": [ + "create", + "topup" + ], + "type": "string", + "description": "* `create` - create\n* `topup` - topup" }, - "MemberClaimCodeIssueResponse": { + "MoveToInventoryRequestPublicAvailabilityModeEnum": { + "enum": [ + "exact_count", + "status_only", + "hidden" + ], + "type": "string", + "description": "* `exact_count` - exact_count\n* `status_only` - status_only\n* `hidden` - hidden" + }, + "MoveToInventoryRequestTrackingModeEnum": { + "enum": [ + "quantity", + "individual" + ], + "type": "string", + "description": "* `quantity` - quantity\n* `individual` - individual" + }, + "MoveToPrintingRequest": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true + "target": { + "$ref": "#/components/schemas/TargetEnum" }, - "membership_id": { + "printer": { "type": "integer", - "readOnly": true + "nullable": true }, - "member_display_name": { + "material": { "type": "string", - "readOnly": true + "maxLength": 100 }, - "issued_by_id": { - "type": "integer", - "readOnly": true, - "nullable": true + "color": { + "type": "string", + "maxLength": 100 }, - "issued_at": { + "brand": { "type": "string", - "format": "date-time", - "readOnly": true + "maxLength": 100 }, - "expires_at": { + "lot_code": { "type": "string", - "format": "date-time", - "readOnly": true + "maxLength": 100 }, - "consumed_at": { + "initial_weight_grams": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "format": "decimal", + "pattern": "^-?\\d{0,6}(?:\\.\\d{0,2})?$" }, - "revoked_at": { + "remaining_weight_grams": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,6}(?:\\.\\d{0,2})?$" + }, + "is_active": { + "type": "boolean" + }, + "opened_at": { "type": "string", "format": "date-time", - "readOnly": true, "nullable": true }, - "status": { + "name": { "type": "string", - "readOnly": true + "maxLength": 200 }, - "code": { + "model": { "type": "string", - "readOnly": true + "maxLength": 200 }, - "qr_svg": { - "type": "string", - "readOnly": true + "status": { + "$ref": "#/components/schemas/MoveToPrintingRequestStatusEnum" + }, + "notes": { + "type": "string" } }, "required": [ - "code", - "consumed_at", - "expires_at", - "id", - "issued_at", - "issued_by_id", - "member_display_name", - "membership_id", - "qr_svg", - "revoked_at", - "status" + "target" ] }, - "MemberDues": { + "MoveToPrintingRequestStatusEnum": { + "enum": [ + "active", + "maintenance", + "offline" + ], + "type": "string", + "description": "* `active` - active\n* `maintenance` - maintenance\n* `offline` - offline" + }, + "MyMembershipRequest": { "type": "object", "properties": { - "dues_amount": { + "id": { + "type": "integer" + }, + "makerspace": { + "type": "object", + "additionalProperties": {} + }, + "state": { "type": "string" }, - "outstanding_by_currency": { + "kind": { + "type": "string" + }, + "admission_payment": { "type": "object", - "additionalProperties": { - "type": "string" - }, + "additionalProperties": {}, + "nullable": true + }, + "expires_at": { + "type": "string", + "format": "date-time", "nullable": true } }, "required": [ - "dues_amount", - "outstanding_by_currency" + "admission_payment", + "expires_at", + "id", + "kind", + "makerspace", + "state" ] }, - "MemberEventRegistrationActivity": { + "MyMembershipRow": { "type": "object", "properties": { - "registration_id": { - "type": "integer" - }, - "checkin_token": { - "type": "string", - "format": "uuid", - "nullable": true - }, - "event_title": { - "type": "string" - }, - "starts_at": { - "type": "string", - "format": "date-time" + "makerspace": { + "type": "object", + "additionalProperties": {} }, - "ends_at": { - "type": "string", - "format": "date-time" + "membership_status": { + "type": "string" }, - "status": { + "role": { "type": "string" }, - "cancellation_cutoff_at": { - "type": "string", - "format": "date-time" + "actions": { + "type": "array", + "items": { + "type": "string" + } }, - "can_cancel": { + "can_refer": { "type": "boolean" }, - "waitlist_position": { - "type": "integer", - "nullable": true - }, - "feedback_available": { + "can_verify": { "type": "boolean" }, - "feedback_path": { + "verified_at": { "type": "string", + "format": "date-time", "nullable": true }, - "certificate": { - "type": "object", - "additionalProperties": {}, - "nullable": true + "referrals_enabled": { + "type": "boolean" + }, + "waiver_accepted": { + "type": "boolean" + }, + "waiver_acceptance_required": { + "type": "boolean" } }, "required": [ - "can_cancel", - "cancellation_cutoff_at", - "certificate", - "checkin_token", - "ends_at", - "event_title", - "feedback_available", - "feedback_path", - "registration_id", - "starts_at", - "status", - "waitlist_position" + "actions", + "can_refer", + "can_verify", + "makerspace", + "membership_status", + "referrals_enabled", + "role", + "verified_at", + "waiver_acceptance_required", + "waiver_accepted" ] }, - "MemberKitPreviewInput": { + "MyMemberships": { "type": "object", "properties": { - "payload": { - "type": "string", - "maxLength": 64 + "memberships": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MyMembershipRow" + } }, - "phase": { - "$ref": "#/components/schemas/PhaseEnum" + "requests": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MyMembershipRequest" + } } }, "required": [ - "payload", - "phase" + "memberships", + "requests" ] }, - "MemberLoanActivity": { + "NativeApple": { "type": "object", "properties": { - "request_id": { - "type": "integer" - }, - "renewal_count": { - "type": "integer" - }, - "can_renew": { + "enabled": { "type": "boolean" + } + }, + "required": [ + "enabled" + ] + }, + "NativeConfig": { + "type": "object", + "properties": { + "min_app_version": { + "$ref": "#/components/schemas/NativeMinVersion" }, - "renewal_refusal": { - "type": "string", - "nullable": true - }, - "label": { - "type": "string" - }, - "checked_out_at": { - "type": "string", - "format": "date-time" - }, - "due_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "overdue": { - "type": "boolean" + "providers": { + "$ref": "#/components/schemas/NativeProviders" } }, "required": [ - "can_renew", - "checked_out_at", - "due_at", - "label", - "overdue", - "renewal_count", - "renewal_refusal", - "request_id" + "min_app_version", + "providers" ] }, - "MemberLoanHistory": { + "NativeGoogle": { "type": "object", "properties": { - "label": { + "android_client_id": { "type": "string" }, - "checked_out_at": { - "type": "string", - "format": "date-time" - }, - "returned_at": { + "ios_client_id": { + "type": "string" + } + }, + "required": [ + "android_client_id", + "ios_client_id" + ] + }, + "NativeMinVersion": { + "type": "object", + "properties": { + "android": { "type": "string", - "format": "date-time", - "nullable": true + "nullable": true, + "description": "Highest dotted-numeric minimum among approved global production Android registrations, or null." }, - "due_at": { + "ios": { "type": "string", - "format": "date-time", - "nullable": true - }, - "returned_late": { - "type": "boolean" + "nullable": true, + "description": "Highest dotted-numeric minimum among approved global production Apple registrations, or null." } }, "required": [ - "checked_out_at", - "due_at", - "label", - "returned_at", - "returned_late" + "android", + "ios" ] }, - "MemberMachineServiceActivity": { + "NativeOidc": { "type": "object", "properties": { - "machine_type": { + "key": { "type": "string" }, - "title": { + "display_name": { "type": "string" }, - "status": { + "client_id": { "type": "string" }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "queue_position": { - "type": "integer", - "nullable": true + "issuer": { + "type": "string" } }, "required": [ - "created_at", - "queue_position", - "status", - "title" + "client_id", + "display_name", + "issuer", + "key" ] }, - "MemberNotice": { + "NativeProviders": { "type": "object", - "description": "A fact about the reader, derived from their own rows.\n\nNot `notifications.Notification`: that table is makerspace-wide, has no recipient\nand shares one `read_at`, so serving it would leak staff alerts.", "properties": { - "level": { - "type": "string" + "password": { + "type": "boolean" }, - "event": { - "type": "string" + "phone": { + "type": "boolean" }, - "title": { - "type": "string" + "google": { + "allOf": [ + { + "$ref": "#/components/schemas/NativeGoogle" + } + ], + "nullable": true }, - "body": { - "type": "string" + "apple": { + "allOf": [ + { + "$ref": "#/components/schemas/NativeApple" + } + ], + "nullable": true + }, + "oidc": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NativeOidc" + } } }, "required": [ - "body", - "event", - "level", - "title" + "apple", + "google", + "oidc", + "password", + "phone" ] }, - "MemberPayment": { + "NeedsFixAction": { + "type": "object", + "properties": { + "action": { + "$ref": "#/components/schemas/NeedsFixActionActionEnum" + }, + "quantity": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "action", + "quantity" + ] + }, + "NeedsFixActionActionEnum": { + "enum": [ + "repair", + "scrap", + "shelve" + ], + "type": "string", + "description": "* `repair` - repair\n* `scrap` - scrap\n* `shelve` - shelve" + }, + "NegativesEnum": { + "enum": [ + "allow", + "clip" + ], + "type": "string", + "description": "* `allow` - allow\n* `clip` - clip" + }, + "Notification": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "subject_type": { - "$ref": "#/components/schemas/SubjectTypeEnum" - }, - "subject_label": { - "type": "string", + "level": { + "allOf": [ + { + "$ref": "#/components/schemas/LevelEnum" + } + ], "readOnly": true }, - "status": { - "$ref": "#/components/schemas/Status66aEnum" - }, - "amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - }, - "currency": { - "type": "string", - "pattern": "^[a-z]{3}$", - "maxLength": 3 - }, - "due_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "due_date": { + "event": { "type": "string", - "nullable": true, "readOnly": true }, - "due_state": { + "title": { "type": "string", "readOnly": true }, - "checkout_url": { + "body": { "type": "string", "readOnly": true }, - "online_payment_available": { - "type": "boolean", - "description": "Whether offering to pay online is honest for this charge.\n\nResolved once per makerspace by the view and passed in: doing it per row would\nre-read settings and credentials for every charge in the list. Falls back to a\nlive check only when a caller has not supplied it.", - "readOnly": true - }, - "settlement": { - "allOf": [ - { - "$ref": "#/components/schemas/MemberSettlement" - } - ], - "nullable": true, - "readOnly": true - }, - "refunded_amount": { - "type": "number", - "format": "double", + "url_path": { + "type": "string", "readOnly": true }, - "refunds": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberRefund" - }, - "readOnly": true + "read_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true }, "created_at": { "type": "string", @@ -85044,1101 +107338,1041 @@ } }, "required": [ - "amount", - "checkout_url", + "body", "created_at", - "currency", - "due_date", - "due_state", + "event", "id", - "online_payment_available", - "refunded_amount", - "refunds", - "settlement", - "subject_label", - "subject_type" + "level", + "read_at", + "title", + "url_path" ] }, - "MemberPresenceActivity": { + "NotificationChannel": { "type": "object", "properties": { - "started_at": { - "type": "string", - "format": "date-time" - }, - "expires_at": { - "type": "string", - "format": "date-time" - }, - "ended_at": { - "type": "string", - "format": "date-time", - "nullable": true + "key": { + "$ref": "#/components/schemas/Key292Enum" }, - "end_reason": { + "label": { "type": "string" - }, - "active": { - "type": "boolean" } }, "required": [ - "active", - "end_reason", - "ended_at", - "expires_at", - "started_at" + "key", + "label" ] }, - "MemberPrintActivity": { + "NotificationDestination": { "type": "object", "properties": { - "public_token": { + "id": { + "type": "integer", + "readOnly": true + }, + "channel": { + "allOf": [ + { + "$ref": "#/components/schemas/Channel59dEnum" + } + ], + "readOnly": true + }, + "label": { "type": "string", - "format": "uuid" + "readOnly": true }, - "status": { - "type": "string" + "telegram_chat_id": { + "type": "string", + "readOnly": true }, - "title": { - "type": "string" + "is_active": { + "type": "boolean", + "readOnly": true }, - "created_at": { + "payload_version": { + "allOf": [ + { + "$ref": "#/components/schemas/NotificationDestinationPayloadVersionEnum" + } + ], + "readOnly": true + }, + "credential_set": { "type": "string", - "format": "date-time" + "readOnly": true }, - "accepted_at": { + "signing_secret_set": { "type": "string", - "format": "date-time", - "nullable": true + "readOnly": true }, - "started_at": { + "scope": { "type": "string", - "format": "date-time", - "nullable": true + "readOnly": true }, - "completed_at": { + "created_at": { "type": "string", "format": "date-time", - "nullable": true - }, - "estimated_minutes": { - "type": "integer" - }, - "queue_position": { - "type": "integer", - "nullable": true - }, - "queue_approved_ahead": { - "type": "integer", - "nullable": true + "readOnly": true }, - "queue_awaiting_review_ahead": { - "type": "integer", - "nullable": true + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true } }, "required": [ - "accepted_at", - "completed_at", + "channel", "created_at", - "estimated_minutes", - "public_token", - "queue_approved_ahead", - "queue_awaiting_review_ahead", - "queue_position", - "started_at", - "status", - "title" + "credential_set", + "id", + "is_active", + "label", + "payload_version", + "scope", + "signing_secret_set", + "telegram_chat_id", + "updated_at" ] }, - "MemberRefund": { + "NotificationDestinationPayloadVersionEnum": { + "enum": [ + 1, + 2 + ], + "type": "integer", + "description": "* `1` - Legacy\n* `2` - Structured" + }, + "NotificationDestinationWrite": { "type": "object", - "description": "Money sent back on the member's own charge. Never names the staff member.", "properties": { - "amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - }, - "currency": { - "type": "string" - }, - "status": { - "type": "string" - }, - "reason": { - "type": "string" + "channel": { + "$ref": "#/components/schemas/Channel59dEnum" }, - "settled_at": { + "label": { "type": "string", - "format": "date-time", - "nullable": true - }, - "method": { - "type": "string" + "maxLength": 80 }, - "reference": { - "type": "string" + "webhook_url": { + "type": "string", + "writeOnly": true, + "maxLength": 2000 }, - "refunded_at": { + "telegram_chat_id": { "type": "string", - "format": "date-time", - "nullable": true - } - }, - "required": [ - "amount", - "currency", - "method", - "reason", - "reference", - "refunded_at", - "settled_at", - "status" - ] - }, - "MemberRegistrationCancelResponse": { - "type": "object", - "properties": { - "status": { - "allOf": [ + "maxLength": 64 + }, + "signing_secret": { + "writeOnly": true, + "oneOf": [ + { + "type": "string", + "maxLength": 512, + "minLength": 16 + }, { - "$ref": "#/components/schemas/MemberRegistrationCancelResponseStatusEnum" + "type": "string", + "maxLength": 0 } - ], - "readOnly": true + ] + }, + "is_active": { + "type": "boolean", + "default": true + }, + "payload_version": { + "$ref": "#/components/schemas/NotificationDestinationWritePayloadVersionEnum" + }, + "scope": { + "$ref": "#/components/schemas/DestinationScope" } }, "required": [ - "status" + "channel", + "label" ] }, - "MemberRegistrationCancelResponseStatusEnum": { + "NotificationDestinationWritePayloadVersionEnum": { "enum": [ - "cancelled" + 1, + 2 ], - "type": "string", - "description": "* `cancelled` - cancelled" + "type": "integer", + "description": "* `1` - 1\n* `2` - 2" }, - "MemberRequestHistory": { + "NotificationFeature": { "type": "object", "properties": { - "status": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "item_count": { - "type": "integer" - }, - "returned_quantity": { - "type": "integer" + "key": { + "$ref": "#/components/schemas/Key4feEnum" }, - "damaged_quantity": { - "type": "integer" + "label": { + "type": "string" }, - "missing_quantity": { - "type": "integer" + "events": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ - "created_at", - "damaged_quantity", - "item_count", - "missing_quantity", - "returned_quantity", - "status" + "events", + "key", + "label" ] }, - "MemberRetentionReport": { + "NotificationMarkAllRead": { "type": "object", "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "typed_rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberRetentionRow" - } - }, - "meta": { - "type": "object", - "additionalProperties": {} - }, - "report_key": { - "type": "string" + "updated": { + "type": "integer" } }, "required": [ - "meta", - "report_key", - "rows", - "typed_rows" + "updated" ] }, - "MemberRetentionRow": { + "NotificationPreferenceCell": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" - }, - "cohort_month": { - "type": "string" - }, - "months_after_join": { - "type": "integer" - }, - "cohort_members": { - "type": "integer" - }, - "eligible_members": { - "type": "integer" - }, - "retained_members": { - "type": "integer" + "feature": { + "$ref": "#/components/schemas/Feature4feEnum" }, - "retention_percent": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", - "nullable": true + "channel": { + "$ref": "#/components/schemas/Channel292Enum" }, - "churned_members": { - "type": "integer" + "enabled": { + "type": "boolean" }, - "reactivated_members": { - "type": "integer" + "source": { + "$ref": "#/components/schemas/NotificationPreferenceCellSourceEnum" } }, "required": [ - "churned_members", - "cohort_members", - "cohort_month", - "eligible_members", - "months_after_join", - "reactivated_members", - "retained_members", - "retention_percent" + "channel", + "enabled", + "feature", + "source" ] }, - "MemberSettlement": { + "NotificationPreferenceCellSourceEnum": { + "enum": [ + "default", + "override" + ], + "type": "string", + "description": "* `default` - default\n* `override` - override" + }, + "NotificationPreferenceChange": { "type": "object", - "description": "The receipt for a charge staff settled in person, shown back to the payer.", "properties": { - "method": { - "type": "string" + "feature": { + "$ref": "#/components/schemas/Feature4feEnum" }, - "received_at": { - "type": "string", - "format": "date-time" + "channel": { + "$ref": "#/components/schemas/Channel292Enum" }, - "reference": { - "type": "string" + "enabled": { + "type": "boolean" } }, "required": [ - "method", - "received_at", - "reference" + "channel", + "enabled", + "feature" ] }, - "MemberSignUp": { + "NotificationRecipient": { "type": "object", "properties": { - "display_name": { - "type": "string", - "maxLength": 200 + "id": { + "type": "integer", + "readOnly": true }, - "email": { + "username": { "type": "string", - "format": "email", - "maxLength": 254 + "readOnly": true }, - "phone": { - "type": "string", - "default": "", - "maxLength": 32 + "email": { + "readOnly": true, + "oneOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "string", + "maxLength": 0 + } + ] }, - "password": { + "role": { "type": "string", - "writeOnly": true, - "maxLength": 128 + "readOnly": true }, - "website": { - "type": "string", - "default": "" + "receives_notifications": { + "type": "boolean" } }, "required": [ - "display_name", "email", - "password" + "id", + "receives_notifications", + "role", + "username" ] }, - "MemberTerm": { + "NotificationRecipientUpdate": { "type": "object", "properties": { - "plan_name": { - "type": "string" - }, - "starts_at": { - "type": "string", - "format": "date-time" - }, - "ends_at": { - "type": "string", - "format": "date-time" - }, - "current": { - "type": "boolean" + "id": { + "type": "integer" }, - "blocks_borrowing": { + "receives_notifications": { "type": "boolean" } }, "required": [ - "blocks_borrowing", - "current", - "ends_at", - "plan_name", - "starts_at" + "id", + "receives_notifications" ] }, - "MemberVerificationAck": { + "NotificationRuleCatalogItem": { "type": "object", "properties": { - "detail": { + "stream": { + "type": "string" + }, + "audience": { "type": "string" + }, + "targets": { + "type": "array", + "items": { + "type": "string" + } + }, + "events": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ - "detail" + "audience", + "events", + "stream", + "targets" ] }, - "MemberVerificationRequest": { + "NotificationRuleChange": { "type": "object", "properties": { - "membership_id": { - "type": "integer", - "minimum": 1 + "target": { + "type": "string" }, - "verified": { + "stream": { + "type": "string" + }, + "event": { + "type": "string" + }, + "audience": { + "type": "string" + }, + "muted": { "type": "boolean" } }, "required": [ - "membership_id", - "verified" + "audience", + "event", + "muted", + "stream", + "target" ] }, - "MemberVerificationResponse": { + "NotificationRuleMute": { "type": "object", "properties": { - "membership_id": { - "type": "integer" + "target": { + "type": "string" }, - "display_name": { + "stream": { "type": "string" }, - "verified_at": { - "type": "string", - "format": "date-time", - "nullable": true + "event": { + "type": "string" + }, + "audience": { + "type": "string" } }, "required": [ - "display_name", - "membership_id", - "verified_at" + "audience", + "event", + "stream", + "target" ] }, - "MemberWaiverResponse": { + "NotificationRulesResponse": { "type": "object", "properties": { - "has_waiver": { - "type": "boolean" + "catalog": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationRuleCatalogItem" + } }, - "body": { - "type": "string" + "mutes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationRuleMute" + } }, - "version": { - "type": "string" + "channels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationChannel" + } + }, + "features": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationFeature" + } + }, + "preferences": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationPreferenceCell" + } } }, "required": [ - "has_waiver" + "catalog", + "channels", + "features", + "mutes", + "preferences" ] }, - "MembershipAccount": { + "NotificationUnreadCount": { "type": "object", "properties": { - "status": { - "$ref": "#/components/schemas/MembershipAccountStatusEnum" - }, - "renewal_cancelled_at": { - "type": "string", - "format": "date-time", - "nullable": true + "count": { + "type": "integer" } }, "required": [ - "renewal_cancelled_at", - "status" + "count" ] }, - "MembershipAccountStatusEnum": { + "NullEnum": { "enum": [ - "active", - "revoked" - ], - "type": "string", - "description": "* `active` - active\n* `revoked` - revoked" - }, - "MembershipApprovalOutcome": { - "oneOf": [ - { - "$ref": "#/components/schemas/AdminMembership" - }, - { - "$ref": "#/components/schemas/MembershipRequest" - } + null ] }, - "MembershipCreate": { + "OccurrenceResult": { "type": "object", "properties": { - "username": { - "type": "string", - "maxLength": 150 - }, - "email": { - "oneOf": [ - { - "type": "string", - "format": "email", - "maxLength": 254 - }, - { - "type": "string", - "maxLength": 0 - } - ] + "occurrence": { + "type": "integer" }, - "first_name": { + "starts_at": { "type": "string", - "maxLength": 150 + "format": "date-time" }, - "last_name": { + "ends_at": { "type": "string", - "maxLength": 150 + "format": "date-time" }, - "password": { - "type": "string", - "writeOnly": true + "booking_id": { + "type": "integer", + "nullable": true }, - "role_id": { - "type": "integer" + "status": { + "type": "string" + }, + "detail": { + "type": "string" } }, "required": [ - "role_id", - "username" + "booking_id", + "detail", + "ends_at", + "occurrence", + "starts_at", + "status" ] }, - "MembershipDispositionEnum": { - "enum": [ - "import_membership", - "no_membership" - ], - "type": "string", - "description": "* `import_membership` - Import membership\n* `no_membership` - Do not import membership" - }, - "MembershipList": { + "OfflineCheckInOperation": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "user": { - "allOf": [ - { - "$ref": "#/components/schemas/User" - } - ], - "readOnly": true - }, - "makerspace_id": { - "type": "integer", - "readOnly": true - }, - "makerspace_slug": { + "operation_id": { "type": "string", - "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" + "format": "uuid" }, - "role": { - "allOf": [ - { - "$ref": "#/components/schemas/Role827Enum" - } - ], - "readOnly": true + "checkin_token": { + "type": "string", + "maxLength": 64 }, - "assigned_role": { - "allOf": [ - { - "$ref": "#/components/schemas/MembershipRoleSummary" - } - ], - "readOnly": true, - "nullable": true + "reported_occurred_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "checkin_token", + "operation_id", + "reported_occurred_at" + ] + }, + "OfflineCheckInResult": { + "type": "object", + "properties": { + "operation_id": { + "type": "string", + "format": "uuid" }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/Status37fEnum" - } - ], - "readOnly": true + "outcome": { + "$ref": "#/components/schemas/OfflineCheckInResultOutcomeEnum" }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "registration_id": { + "type": "integer" }, - "payment": { - "allOf": [ - { - "$ref": "#/components/schemas/StaffPaymentSummary" - } - ], - "nullable": true, - "readOnly": true + "attended_at": { + "type": "string", + "format": "date-time" } }, "required": [ - "assigned_role", - "created_at", - "id", - "makerspace_id", - "makerspace_slug", - "payment", - "role", - "status", - "user" + "operation_id", + "outcome" ] }, - "MembershipListResponse": { + "OfflineCheckInResultOutcomeEnum": { + "enum": [ + "applied", + "duplicate_operation", + "already_attended", + "registration_changed", + "event_unavailable", + "invalid_token", + "outside_window" + ], + "type": "string", + "description": "* `applied` - applied\n* `duplicate_operation` - duplicate_operation\n* `already_attended` - already_attended\n* `registration_changed` - registration_changed\n* `event_unavailable` - event_unavailable\n* `invalid_token` - invalid_token\n* `outside_window` - outside_window" + }, + "OfflineCheckInSyncRequest": { "type": "object", "properties": { - "count": { - "type": "integer" - }, - "next": { + "lease_token": { "type": "string", - "format": "uri", - "nullable": true + "maxLength": 8192 }, - "previous": { + "operations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OfflineCheckInOperation" + } + } + }, + "required": [ + "lease_token", + "operations" + ] + }, + "OfflineCheckInSyncResponse": { + "type": "object", + "properties": { + "recorded_at": { "type": "string", - "format": "uri", - "nullable": true + "format": "date-time" }, "results": { "type": "array", "items": { - "$ref": "#/components/schemas/AdminMembership" + "$ref": "#/components/schemas/OfflineCheckInResult" } } }, "required": [ - "count", - "next", - "previous", + "recorded_at", "results" ] }, - "MembershipOutcome": { + "OfflineRosterEvent": { "type": "object", "properties": { - "outcome": { - "$ref": "#/components/schemas/MembershipOutcomeOutcomeEnum" - }, - "membership_id": { - "type": "integer" - }, - "request_id": { + "id": { "type": "integer" }, - "state": { + "title": { "type": "string" + }, + "starts_at": { + "type": "string", + "format": "date-time" + }, + "ends_at": { + "type": "string", + "format": "date-time" } }, "required": [ - "outcome", - "state" + "ends_at", + "id", + "starts_at", + "title" ] }, - "MembershipOutcomeOutcomeEnum": { - "enum": [ - "joined", - "requested" - ], - "type": "string", - "description": "* `joined` - joined\n* `requested` - requested" - }, - "MembershipPlan": { + "OfflineRosterRegistration": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true + "registration_id": { + "type": "integer" }, - "name": { + "checkin_token": { "type": "string", - "maxLength": 120 + "format": "uuid" }, - "interval": { - "$ref": "#/components/schemas/IntervalEnum" + "name": { + "type": "string" }, - "custom_days": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0, - "nullable": true + "host_waiver_state": { + "$ref": "#/components/schemas/HostWaiverStateEnum" + } + }, + "required": [ + "checkin_token", + "host_waiver_state", + "name", + "registration_id" + ] + }, + "OfflineRosterResponse": { + "type": "object", + "properties": { + "lease_token": { + "type": "string" }, - "amount": { + "lease_id": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "format": "uuid" }, - "currency": { + "server_time": { "type": "string", - "maxLength": 3 - }, - "is_active": { - "type": "boolean" + "format": "date-time" }, - "created_at": { + "issued_at": { "type": "string", - "format": "date-time", - "readOnly": true + "format": "date-time" }, - "updated_at": { + "expires_at": { "type": "string", - "format": "date-time", - "readOnly": true - }, - "loan_max_active": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0, - "nullable": true - }, - "loan_max_items": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0, - "nullable": true + "format": "date-time" }, - "auto_renew": { - "type": "boolean" + "scan_opens_at": { + "type": "string", + "format": "date-time" }, - "renewal_open_days": { - "type": "integer", - "maximum": 365, - "minimum": 0 + "scan_closes_at": { + "type": "string", + "format": "date-time" }, - "renewal_grace_days": { - "type": "integer", - "maximum": 365, - "minimum": 0 + "sync_deadline": { + "type": "string", + "format": "date-time" }, - "renewal_retries": { - "type": "integer", - "maximum": 3, - "minimum": 0 + "event": { + "$ref": "#/components/schemas/OfflineRosterEvent" }, - "reminder_days_before": {}, - "reminder_days_after": {} + "registrations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OfflineRosterRegistration" + } + } }, "required": [ - "created_at", - "id", - "interval", - "name", - "updated_at" + "event", + "expires_at", + "issued_at", + "lease_id", + "lease_token", + "registrations", + "scan_closes_at", + "scan_opens_at", + "server_time", + "sync_deadline" ] }, - "MembershipPolicyEnum": { - "enum": [ - "request", - "open", - "invite_only" - ], - "type": "string", - "description": "* `request` - Request\n* `open` - Open\n* `invite_only` - Invite only" - }, - "MembershipReason": { + "OidcBrowserCallback": { "type": "object", "properties": { - "reason": { + "code": { "type": "string", - "maxLength": 1000 + "maxLength": 4096 + }, + "state": { + "type": "string", + "maxLength": 512 + }, + "nonce": { + "type": "string", + "maxLength": 512 } }, "required": [ - "reason" + "code", + "nonce", + "state" ] }, - "MembershipRequest": { + "OidcBrowserLoginResponse": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "kind": { - "$ref": "#/components/schemas/MembershipRequestKindEnum" - }, - "state": { - "$ref": "#/components/schemas/State9efEnum" - }, - "invite_email": { - "type": "string", - "maxLength": 254 + "access": { + "type": "string" }, "user": { - "allOf": [ - { - "$ref": "#/components/schemas/MembershipRequestUser" - } - ], - "nullable": true, - "readOnly": true + "type": "object", + "additionalProperties": {} }, - "assigned_role": { + "outcome": { + "type": "string" + } + }, + "required": [ + "access", + "outcome", + "user" + ] + }, + "OidcBrowserStart": { + "type": "object", + "properties": { + "surface": { "allOf": [ { - "$ref": "#/components/schemas/MembershipRequestRole" + "$ref": "#/components/schemas/Surface356Enum" } ], - "nullable": true, - "readOnly": true - }, - "decision_note": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "default": "member" }, - "decided_at": { + "redirect_uri": { "type": "string", - "format": "date-time", - "nullable": true - }, - "application_answers": { - "nullable": true, - "readOnly": true - }, - "vouches": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Vouch" - } + "format": "uri", + "maxLength": 2048 }, - "admission_payment": { - "allOf": [ + "email": { + "oneOf": [ { - "$ref": "#/components/schemas/AdmissionPayment" + "type": "string", + "format": "email" + }, + { + "type": "string", + "maxLength": 0 } - ], - "nullable": true, - "readOnly": true + ] }, - "expires_at": { + "makerspace_slug": { + "oneOf": [ + { + "type": "string", + "pattern": "^[-a-zA-Z0-9_]+$" + }, + { + "type": "string", + "maxLength": 0 + } + ] + } + }, + "required": [ + "redirect_uri" + ] + }, + "OidcBrowserStartResponse": { + "type": "object", + "properties": { + "authorization_url": { "type": "string", - "format": "date-time", - "nullable": true + "format": "uri" + }, + "state": { + "type": "string" + }, + "nonce": { + "type": "string" + }, + "expires_in": { + "type": "integer" } }, "required": [ - "admission_payment", - "application_answers", - "assigned_role", - "created_at", - "id", - "kind", - "state", - "user", - "vouches" + "authorization_url", + "expires_in", + "nonce", + "state" ] }, - "MembershipRequestCreate": { + "OperatorCandidate": { "type": "object", "properties": { - "application_answers": { - "nullable": true + "user_id": { + "type": "integer", + "readOnly": true }, - "website": { + "username": { "type": "string", - "writeOnly": true + "readOnly": true + }, + "display_name": { + "type": "string", + "readOnly": true } - } + }, + "required": [ + "display_name", + "user_id", + "username" + ] }, - "MembershipRequestKindEnum": { + "OperatorEnum": { "enum": [ - "request", - "invite" + "gt", + "gte", + "lt", + "lte", + "eq" ], "type": "string", - "description": "* `request` - Request\n* `invite` - Invite" + "description": "* `gt` - gt\n* `gte` - gte\n* `lt` - lt\n* `lte` - lte\n* `eq` - eq" }, - "MembershipRequestListResponse": { + "OrganizationCreate": { "type": "object", "properties": { - "count": { - "type": "integer" - }, - "next": { + "name": { "type": "string", - "format": "uri", - "nullable": true + "maxLength": 200 }, - "previous": { + "slug": { "type": "string", - "format": "uri", - "nullable": true + "maxLength": 50, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MembershipRequest" - } + "description": { + "type": "string" + }, + "website": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "maxLength": 200 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "public_profile_enabled": { + "type": "boolean" } }, "required": [ - "count", - "next", - "previous", - "results" + "name", + "slug" ] }, - "MembershipRequestRole": { + "OrganizationCreationPolicy": { "type": "object", "properties": { - "id": { + "enabled": { + "type": "boolean" + }, + "eligible": { + "type": "boolean" + }, + "max_owned": { "type": "integer" }, - "name": { + "max_per_day": { + "type": "integer" + }, + "reason": { "type": "string" } }, "required": [ - "id", - "name" + "eligible", + "enabled", + "max_owned", + "max_per_day", + "reason" ] }, - "MembershipRequestUser": { + "OrganizationDetail": { "type": "object", "properties": { "id": { - "type": "integer" + "type": "integer", + "readOnly": true }, - "username": { - "type": "string" + "slug": { + "type": "string", + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "email": { + "name": { "type": "string", - "format": "email" - } - }, - "required": [ - "email", - "id", - "username" - ] - }, - "MembershipRevenueReport": { - "type": "object", - "properties": { - "rows": { + "readOnly": true + }, + "is_active": { + "type": "boolean", + "readOnly": true + }, + "governance_actions": { "type": "array", "items": { - "type": "array", - "items": {} - } + "type": "string" + }, + "readOnly": true }, - "typed_rows": { + "granted_actions": { "type": "array", "items": { - "$ref": "#/components/schemas/MembershipRevenueRow" - } - }, - "meta": { - "type": "object", - "additionalProperties": {} + "type": "string" + }, + "readOnly": true }, - "report_key": { - "type": "string" - } - }, - "required": [ - "meta", - "report_key", - "rows", - "typed_rows" - ] - }, - "MembershipRevenueRow": { - "type": "object", - "properties": { - "makerspace_id": { - "type": "integer" + "description": { + "type": "string", + "readOnly": true }, - "period": { + "website": { "type": "string", - "format": "date" + "format": "uri", + "readOnly": true }, - "currency": { - "type": "string" + "logo_url": { + "type": "string", + "format": "uri", + "nullable": true, + "readOnly": true }, - "recurring_members": { - "type": "integer" + "public_profile_enabled": { + "type": "boolean", + "readOnly": true }, - "mrr": { + "legal_name": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$" + "readOnly": true }, - "arr": { + "registration_number": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$" + "readOnly": true }, - "arpu": { + "contact_email": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$" - }, - "churned_members": { - "type": "integer" + "format": "email", + "readOnly": true }, - "reactivated_members": { - "type": "integer" + "billing_email": { + "type": "string", + "format": "email", + "readOnly": true } }, "required": [ - "arpu", - "arr", - "churned_members", - "currency", - "mrr", - "period", - "reactivated_members", - "recurring_members" + "billing_email", + "contact_email", + "description", + "governance_actions", + "granted_actions", + "id", + "is_active", + "legal_name", + "logo_url", + "name", + "public_profile_enabled", + "registration_number", + "slug", + "website" ] }, - "MembershipRoleSummary": { + "OrganizationEventHost": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "name": { - "type": "string", - "readOnly": true - }, "slug": { "type": "string", "readOnly": true, "pattern": "^[-a-zA-Z0-9_]+$" }, - "legacy_role": { - "readOnly": true, - "nullable": true, - "oneOf": [ - { - "$ref": "#/components/schemas/LegacyRoleEnum" - }, - { - "$ref": "#/components/schemas/NullEnum" - } - ] - }, - "is_default": { - "type": "boolean", + "name": { + "type": "string", "readOnly": true }, - "is_protected": { - "type": "boolean", + "logo_url": { + "type": "string", + "format": "uri", + "nullable": true, "readOnly": true } }, "required": [ - "id", - "is_default", - "is_protected", - "legacy_role", + "logo_url", "name", "slug" ] }, - "MembershipTerm": { + "OrganizationInvitation": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "membership": { + "organization_id": { "type": "integer", "readOnly": true }, - "plan": { - "type": "integer", + "governance_actions": { "readOnly": true }, - "plan_name": { - "type": "string", + "granted_actions": { "readOnly": true }, - "starts_at": { + "expires_at": { "type": "string", "format": "date-time", "readOnly": true }, - "ends_at": { + "redeemed_at": { "type": "string", "format": "date-time", - "readOnly": true + "readOnly": true, + "nullable": true }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/MembershipTermStatusEnum" - } - ], - "readOnly": true + "revoked_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true }, - "renewal_payment": { + "created_by_id": { "type": "integer", "readOnly": true, "nullable": true }, - "created_by": { + "redeemed_by_id": { "type": "integer", "readOnly": true, "nullable": true @@ -86147,685 +108381,662 @@ "type": "string", "format": "date-time", "readOnly": true + }, + "state": { + "allOf": [ + { + "$ref": "#/components/schemas/State31eEnum" + } + ], + "readOnly": true } }, "required": [ "created_at", - "created_by", - "ends_at", + "created_by_id", + "expires_at", + "governance_actions", + "granted_actions", "id", - "membership", - "plan", - "plan_name", - "renewal_payment", - "starts_at", - "status" + "organization_id", + "redeemed_at", + "redeemed_by_id", + "revoked_at", + "state" ] }, - "MembershipTermCreate": { + "OrganizationInvitationCreate": { "type": "object", "properties": { - "plan_id": { - "type": "integer" + "governance_actions": { + "type": "array", + "items": { + "type": "string" + } }, - "starts_at": { - "type": "string", - "format": "date-time", - "nullable": true - } - }, - "required": [ - "plan_id" - ] - }, - "MembershipTermStatusEnum": { - "enum": [ - "active", - "expired", - "cancelled" - ], - "type": "string", - "description": "* `active` - Active\n* `expired` - Expired\n* `cancelled` - Cancelled" - }, - "Method2edEnum": { - "enum": [ - "cash", - "upi", - "bank_transfer", - "card_machine", - "cheque", - "other" - ], - "type": "string", - "description": "* `cash` - Cash\n* `upi` - UPI\n* `bank_transfer` - Bank transfer\n* `card_machine` - Card machine\n* `cheque` - Cheque\n* `other` - Other" - }, - "MigrationExportCreate": { - "type": "object", - "properties": { - "approval_id": { - "type": "string", - "format": "uuid" + "granted_actions": { + "type": "array", + "items": { + "type": "string" + } }, - "target_age_recipient": { - "type": "string", - "writeOnly": true, - "maxLength": 256 + "expires_in_days": { + "type": "integer", + "maximum": 30, + "minimum": 1, + "default": 7 } - }, - "required": [ - "approval_id", - "target_age_recipient" - ] + } }, - "MigrationExportJob": { + "OrganizationInvitationCreated": { "type": "object", "properties": { "id": { - "type": "string", - "format": "uuid", + "type": "integer", "readOnly": true }, - "status": { - "$ref": "#/components/schemas/StatusE1dEnum" - }, - "failure_code": { - "oneOf": [ - { - "$ref": "#/components/schemas/FailureCodeEnum" - }, - { - "$ref": "#/components/schemas/BlankEnum" - } - ] - }, - "failure_detail": { - "type": "string", - "maxLength": 500 - }, - "manifest": {}, - "closure_digest": { - "type": "string" - }, - "archive_digest": { - "type": "string" + "organization_id": { + "type": "integer", + "readOnly": true }, - "format_version": { - "type": "integer" + "governance_actions": { + "readOnly": true }, - "source_retention_notice": { - "type": "string", + "granted_actions": { "readOnly": true }, - "created_at": { + "expires_at": { "type": "string", "format": "date-time", "readOnly": true }, - "started_at": { + "redeemed_at": { "type": "string", "format": "date-time", + "readOnly": true, "nullable": true }, - "completed_at": { + "revoked_at": { "type": "string", "format": "date-time", + "readOnly": true, "nullable": true }, - "expires_at": { + "created_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "redeemed_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { "type": "string", - "format": "date-time" + "format": "date-time", + "readOnly": true + }, + "state": { + "allOf": [ + { + "$ref": "#/components/schemas/State31eEnum" + } + ], + "readOnly": true + }, + "token": { + "type": "string", + "readOnly": true + }, + "redeem_path": { + "type": "string", + "readOnly": true } }, "required": [ - "archive_digest", - "closure_digest", "created_at", + "created_by_id", "expires_at", - "format_version", + "governance_actions", + "granted_actions", "id", - "source_retention_notice" + "organization_id", + "redeem_path", + "redeemed_at", + "redeemed_by_id", + "revoked_at", + "state", + "token" ] }, - "MobilePaymentIntentResponse": { + "OrganizationInvitationList": { "type": "object", "properties": { - "payment_id": { + "count": { "type": "integer" }, - "client_secret": { - "type": "string" - }, - "publishable_key": { - "type": "string" - }, - "customer_id": { + "next": { "type": "string", "nullable": true }, - "ephemeral_key": { + "previous": { "type": "string", "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrganizationInvitation" + } } }, "required": [ - "client_secret", - "payment_id", - "publishable_key" + "count", + "next", + "previous", + "results" ] }, - "Mode087Enum": { - "enum": [ - "preview", - "apply" - ], - "type": "string", - "description": "* `preview` - Preview\n* `apply` - Apply" - }, - "ModuleAction": { + "OrganizationInvitationRedeem": { "type": "object", - "description": "The one field either mutation takes.\n\nA real class rather than `inline_serializer`, which returns an *instance* and so\ncannot be called with `data=` at request time.", "properties": { - "key": { - "type": "string" + "token": { + "type": "string", + "maxLength": 200, + "minLength": 20 } }, "required": [ - "key" + "token" ] }, - "ModulesEnum": { - "enum": [ - "asset_units", - "backup", - "bookings", - "bulk_import", - "containers", - "devices", - "discord", - "email", - "events", - "evidence_uploads", - "guest_handover", - "machine_service", - "machines", - "maintenance", - "mattermost", - "member_accounts", + "OrganizationInvitationRedeemed": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/AuthUserPayload" + }, + "membership": { + "$ref": "#/components/schemas/OrganizationMembership" + } + }, + "required": [ "membership", - "mobile", - "notifications", - "payments", - "printing", - "procurement", - "public_inventory", - "qr_management", - "qr_print_batches", - "reports", - "request_workflow", - "scanner", - "slack", - "staff_admin", - "stock_transfers", - "stocktake", - "telegram", - "webhook" - ], - "type": "string", - "description": "* `asset_units` - asset_units\n* `backup` - backup\n* `bookings` - bookings\n* `bulk_import` - bulk_import\n* `containers` - containers\n* `devices` - devices\n* `discord` - discord\n* `email` - email\n* `events` - events\n* `evidence_uploads` - evidence_uploads\n* `guest_handover` - guest_handover\n* `machine_service` - machine_service\n* `machines` - machines\n* `maintenance` - maintenance\n* `mattermost` - mattermost\n* `member_accounts` - member_accounts\n* `membership` - membership\n* `mobile` - mobile\n* `notifications` - notifications\n* `payments` - payments\n* `printing` - printing\n* `procurement` - procurement\n* `public_inventory` - public_inventory\n* `qr_management` - qr_management\n* `qr_print_batches` - qr_print_batches\n* `reports` - reports\n* `request_workflow` - request_workflow\n* `scanner` - scanner\n* `slack` - slack\n* `staff_admin` - staff_admin\n* `stock_transfers` - stock_transfers\n* `stocktake` - stocktake\n* `telegram` - telegram\n* `webhook` - webhook" + "user" + ] }, - "MostLentReport": { + "OrganizationLink": { "type": "object", "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } + "id": { + "type": "integer", + "readOnly": true }, - "typed_rows": { + "makerspace_id": { + "type": "integer", + "readOnly": true + }, + "makerspace_name": { + "type": "string", + "readOnly": true + }, + "relationship": { + "$ref": "#/components/schemas/RelationshipEnum" + } + }, + "required": [ + "id", + "makerspace_id", + "makerspace_name", + "relationship" + ] + }, + "OrganizationLinkPage": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "format": "uri", + "nullable": true + }, + "previous": { + "type": "string", + "format": "uri", + "nullable": true + }, + "results": { "type": "array", "items": { - "$ref": "#/components/schemas/MostLentReportRow" + "$ref": "#/components/schemas/OrganizationLink" } } }, "required": [ - "rows", - "typed_rows" + "count", + "next", + "previous", + "results" + ] + }, + "OrganizationLinkWrite": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer", + "minimum": 1 + }, + "relationship": { + "$ref": "#/components/schemas/RelationshipEnum" + } + }, + "required": [ + "makerspace_id", + "relationship" ] }, - "MostLentReportRow": { + "OrganizationList": { "type": "object", "properties": { - "makerspace_id": { + "count": { "type": "integer" }, - "product_name": { - "type": "string" + "next": { + "type": "string", + "nullable": true }, - "times_lent": { - "type": "integer" + "previous": { + "type": "string", + "nullable": true }, - "total_quantity_lent": { - "type": "integer" + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrganizationSummary" + } } }, "required": [ - "product_name", - "times_lent", - "total_quantity_lent" + "count", + "next", + "previous", + "results" ] }, - "MoveToInventoryRequest": { + "OrganizationMembership": { "type": "object", "properties": { - "mode": { - "$ref": "#/components/schemas/MoveToInventoryRequestModeEnum" - }, - "product_id": { - "type": "integer", - "nullable": true - }, - "quantity": { + "id": { "type": "integer", - "minimum": 1 + "readOnly": true }, - "box": { + "user_id": { "type": "integer", - "nullable": true + "readOnly": true }, - "category": { - "type": "integer", - "nullable": true + "username": { + "type": "string", + "readOnly": true }, - "tracking_mode": { - "allOf": [ - { - "$ref": "#/components/schemas/MoveToInventoryRequestTrackingModeEnum" - } - ], - "default": "quantity" + "display_name": { + "type": "string", + "readOnly": true }, - "is_public": { - "type": "boolean", - "default": true + "email": { + "type": "string", + "format": "email", + "readOnly": true }, - "public_availability_mode": { + "status": { "allOf": [ { - "$ref": "#/components/schemas/MoveToInventoryRequestPublicAvailabilityModeEnum" + "$ref": "#/components/schemas/StatusE75Enum" } ], - "default": "status_only" + "readOnly": true }, - "show_public_count": { - "type": "boolean", - "default": false + "governance_actions": { + "readOnly": true }, - "public_self_checkout_enabled": { - "type": "boolean", - "default": false + "granted_actions": { + "readOnly": true }, - "name": { + "created_at": { "type": "string", - "maxLength": 200 + "format": "date-time", + "readOnly": true }, - "description": { + "updated_at": { "type": "string", - "default": "" + "format": "date-time", + "readOnly": true } }, "required": [ - "mode", - "quantity" + "created_at", + "display_name", + "email", + "governance_actions", + "granted_actions", + "id", + "status", + "updated_at", + "user_id", + "username" ] }, - "MoveToInventoryRequestModeEnum": { - "enum": [ - "create", - "topup" - ], - "type": "string", - "description": "* `create` - create\n* `topup` - topup" - }, - "MoveToInventoryRequestPublicAvailabilityModeEnum": { - "enum": [ - "exact_count", - "status_only", - "hidden" - ], - "type": "string", - "description": "* `exact_count` - exact_count\n* `status_only` - status_only\n* `hidden` - hidden" - }, - "MoveToInventoryRequestTrackingModeEnum": { - "enum": [ - "quantity", - "individual" - ], - "type": "string", - "description": "* `quantity` - quantity\n* `individual` - individual" - }, - "MoveToPrintingRequest": { + "OrganizationMembershipList": { "type": "object", "properties": { - "target": { - "$ref": "#/components/schemas/TargetEnum" - }, - "printer": { - "type": "integer", - "nullable": true - }, - "material": { - "type": "string", - "maxLength": 100 - }, - "color": { - "type": "string", - "maxLength": 100 - }, - "brand": { - "type": "string", - "maxLength": 100 - }, - "lot_code": { - "type": "string", - "maxLength": 100 - }, - "initial_weight_grams": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,6}(?:\\.\\d{0,2})?$" - }, - "remaining_weight_grams": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,6}(?:\\.\\d{0,2})?$" - }, - "is_active": { - "type": "boolean" + "count": { + "type": "integer" }, - "opened_at": { + "next": { "type": "string", - "format": "date-time", "nullable": true }, - "name": { - "type": "string", - "maxLength": 200 - }, - "model": { + "previous": { "type": "string", - "maxLength": 200 - }, - "status": { - "$ref": "#/components/schemas/MoveToPrintingRequestStatusEnum" + "nullable": true }, - "notes": { - "type": "string" + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrganizationMembership" + } } }, "required": [ - "target" + "count", + "next", + "previous", + "results" ] }, - "MoveToPrintingRequestStatusEnum": { - "enum": [ - "active", - "maintenance", - "offline" - ], - "type": "string", - "description": "* `active` - active\n* `maintenance` - maintenance\n* `offline` - offline" - }, - "MyMembershipRequest": { + "OrganizationReportBreakdown": { "type": "object", "properties": { - "id": { - "type": "integer" - }, - "makerspace": { - "type": "object", - "additionalProperties": {} - }, - "state": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "admission_payment": { - "type": "object", - "additionalProperties": {}, - "nullable": true + "rows": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } }, - "expires_at": { - "type": "string", - "format": "date-time", - "nullable": true + "makerspace_id": { + "type": "integer" } }, "required": [ - "admission_payment", - "expires_at", - "id", - "kind", - "makerspace", - "state" + "makerspace_id", + "rows" ] }, - "MyMembershipRow": { + "OrganizationReportResponse": { "type": "object", "properties": { - "makerspace": { - "type": "object", - "additionalProperties": {} - }, - "membership_status": { + "report_key": { "type": "string" }, - "role": { - "type": "string" + "strategy": { + "$ref": "#/components/schemas/StrategyEnum" }, - "actions": { + "breakdown": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/OrganizationReportBreakdown" } }, - "can_refer": { - "type": "boolean" - }, - "can_verify": { - "type": "boolean" + "total": { + "$ref": "#/components/schemas/OrganizationReportRows" }, - "verified_at": { - "type": "string", - "format": "date-time", + "chart_spec": { + "allOf": [ + { + "$ref": "#/components/schemas/ReportChartSpec" + } + ], "nullable": true - }, - "referrals_enabled": { - "type": "boolean" - }, - "waiver_accepted": { - "type": "boolean" - }, - "waiver_acceptance_required": { - "type": "boolean" } }, "required": [ - "actions", - "can_refer", - "can_verify", - "makerspace", - "membership_status", - "referrals_enabled", - "role", - "verified_at", - "waiver_acceptance_required", - "waiver_accepted" + "breakdown", + "report_key", + "strategy", + "total" ] }, - "MyMemberships": { + "OrganizationReportRows": { "type": "object", "properties": { - "memberships": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MyMembershipRow" - } - }, - "requests": { + "rows": { "type": "array", "items": { - "$ref": "#/components/schemas/MyMembershipRequest" + "type": "object", + "additionalProperties": {} } } }, "required": [ - "memberships", - "requests" + "rows" ] }, - "NeedsFixAction": { + "OrganizationSelfCreate": { "type": "object", "properties": { - "action": { - "$ref": "#/components/schemas/NeedsFixActionActionEnum" + "name": { + "type": "string", + "maxLength": 200 }, - "quantity": { - "type": "integer", - "minimum": 1 + "slug": { + "type": "string", + "maxLength": 50, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "description": { + "type": "string", + "maxLength": 10000 + }, + "website": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "maxLength": 200 + }, + { + "type": "string", + "maxLength": 0 + } + ] } }, "required": [ - "action", - "quantity" + "name", + "slug" ] }, - "NeedsFixActionActionEnum": { - "enum": [ - "repair", - "scrap", - "shelve" - ], - "type": "string", - "description": "* `repair` - repair\n* `scrap` - scrap\n* `shelve` - shelve" - }, - "Notification": { + "OrganizationSummary": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "level": { - "allOf": [ - { - "$ref": "#/components/schemas/LevelEnum" - } - ], - "readOnly": true - }, - "event": { + "slug": { "type": "string", - "readOnly": true + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "title": { + "name": { "type": "string", "readOnly": true }, - "body": { - "type": "string", + "is_active": { + "type": "boolean", "readOnly": true }, - "url_path": { - "type": "string", + "governance_actions": { + "type": "array", + "items": { + "type": "string" + }, "readOnly": true }, - "read_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "created_at": { - "type": "string", - "format": "date-time", + "granted_actions": { + "type": "array", + "items": { + "type": "string" + }, "readOnly": true } }, "required": [ - "body", - "created_at", - "event", + "governance_actions", + "granted_actions", "id", - "level", - "read_at", - "title", - "url_path" + "is_active", + "name", + "slug" ] }, - "NotificationChannel": { + "OrientationEnum": { + "enum": [ + "portrait", + "landscape" + ], + "type": "string", + "description": "* `portrait` - portrait\n* `landscape` - landscape" + }, + "OtpResetPasswordConfirm": { "type": "object", "properties": { - "key": { - "$ref": "#/components/schemas/KeyB30Enum" + "email": { + "type": "string", + "format": "email" }, - "label": { - "type": "string" + "code": { + "type": "string", + "writeOnly": true, + "pattern": "^\\d{6}$" + }, + "new_password": { + "type": "string", + "writeOnly": true } }, "required": [ - "key", - "label" + "code", + "email", + "new_password" ] }, - "NotificationDestination": { + "OutcomeA1aEnum": { + "enum": [ + "succeeded", + "retryable_failed", + "failed" + ], + "type": "string", + "description": "* `succeeded` - succeeded\n* `retryable_failed` - retryable_failed\n* `failed` - failed" + }, + "OverdueSchedule": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "channel": { - "allOf": [ - { - "$ref": "#/components/schemas/Channel59dEnum" - } - ], + "machine_id": { + "type": "integer", "readOnly": true }, - "label": { + "machine_name": { "type": "string", "readOnly": true }, - "telegram_chat_id": { + "description": { + "type": "string", + "readOnly": true + }, + "next_due": { + "type": "string", + "format": "date", + "readOnly": true + } + }, + "required": [ + "description", + "id", + "machine_id", + "machine_name", + "next_due" + ] + }, + "OwnMemberCard": { + "type": "object", + "description": "Never carries the photo key or a URL that outlives the response.", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "card_number": { + "type": "integer", + "readOnly": true + }, + "printed_name": { "type": "string", "readOnly": true }, + "membership_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, "is_active": { "type": "boolean", "readOnly": true }, - "payload_version": { - "allOf": [ - { - "$ref": "#/components/schemas/NotificationDestinationPayloadVersionEnum" - } - ], + "photo_set": { + "type": "boolean", + "readOnly": true + }, + "photo_consent_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "qr_active": { + "type": "boolean", + "readOnly": true + }, + "print_count": { + "type": "integer", "readOnly": true }, - "credential_set": { + "last_printed_at": { "type": "string", - "readOnly": true + "format": "date-time", + "readOnly": true, + "nullable": true }, - "signing_secret_set": { + "issued_at": { "type": "string", + "format": "date-time", "readOnly": true }, - "scope": { + "revoked_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "revoked_reason": { "type": "string", "readOnly": true }, @@ -86838,2083 +109049,2263 @@ "type": "string", "format": "date-time", "readOnly": true + }, + "qr_image": { + "type": "string", + "nullable": true, + "readOnly": true } }, "required": [ - "channel", + "card_number", "created_at", - "credential_set", "id", "is_active", - "label", - "payload_version", - "scope", - "signing_secret_set", - "telegram_chat_id", + "issued_at", + "last_printed_at", + "membership_id", + "photo_consent_at", + "photo_set", + "print_count", + "printed_name", + "qr_active", + "qr_image", + "revoked_at", + "revoked_reason", "updated_at" ] }, - "NotificationDestinationPayloadVersionEnum": { - "enum": [ - 1, - 2 - ], - "type": "integer", - "description": "* `1` - Legacy\n* `2` - Structured" - }, - "NotificationDestinationWrite": { + "PackBookingRules": { "type": "object", + "description": "Query-free shape: bookings may be tombstoned on this deployment.", "properties": { - "channel": { - "$ref": "#/components/schemas/Channel59dEnum" - }, - "label": { - "type": "string", - "maxLength": 80 - }, - "webhook_url": { + "name": { "type": "string", - "writeOnly": true, - "maxLength": 2000 + "maxLength": 200 }, - "telegram_chat_id": { - "type": "string", - "maxLength": 64 + "min_booking_duration_minutes": { + "type": "integer", + "minimum": 1 }, - "signing_secret": { - "writeOnly": true, - "oneOf": [ - { - "type": "string", - "maxLength": 512, - "minLength": 16 - }, - { - "type": "string", - "maxLength": 0 - } - ] + "max_booking_duration_minutes": { + "type": "integer", + "minimum": 1 }, - "is_active": { - "type": "boolean", - "default": true + "booking_lead_time_minutes": { + "type": "integer", + "minimum": 0 }, - "payload_version": { - "$ref": "#/components/schemas/NotificationDestinationWritePayloadVersionEnum" + "max_booking_advance_days": { + "type": "integer", + "minimum": 1 }, - "scope": { - "$ref": "#/components/schemas/DestinationScope" + "approval_mode": { + "$ref": "#/components/schemas/PackBookingRulesApprovalModeEnum" } }, "required": [ - "channel", - "label" + "approval_mode", + "booking_lead_time_minutes", + "max_booking_advance_days", + "max_booking_duration_minutes", + "min_booking_duration_minutes", + "name" ] }, - "NotificationDestinationWritePayloadVersionEnum": { + "PackBookingRulesApprovalModeEnum": { "enum": [ - 1, - 2 + "instant", + "approve" ], - "type": "integer", - "description": "* `1` - 1\n* `2` - 2" + "type": "string", + "description": "* `instant` - instant\n* `approve` - approve" }, - "NotificationFeature": { + "PackCategory": { "type": "object", "properties": { - "key": { - "$ref": "#/components/schemas/Key4feEnum" + "slug": { + "type": "string", + "maxLength": 120, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "label": { - "type": "string" + "name": { + "type": "string", + "maxLength": 100 }, - "events": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "events", - "key", - "label" - ] - }, - "NotificationMarkAllRead": { - "type": "object", - "properties": { - "updated": { - "type": "integer" + "display_order": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "icon": { + "type": "string", + "maxLength": 50 + }, + "loan_days": { + "type": "integer", + "maximum": 365, + "minimum": 1, + "nullable": true + }, + "max_renewals": { + "type": "integer", + "maximum": 100, + "minimum": 0, + "nullable": true } }, "required": [ - "updated" + "display_order", + "icon", + "loan_days", + "max_renewals", + "name", + "slug" ] }, - "NotificationPreferenceCell": { + "PackChange": { "type": "object", "properties": { - "feature": { - "$ref": "#/components/schemas/Feature4feEnum" + "section": { + "type": "string" }, - "channel": { - "$ref": "#/components/schemas/ChannelB30Enum" + "key": { + "type": "string" }, - "enabled": { - "type": "boolean" + "before": { + "nullable": true }, - "source": { - "$ref": "#/components/schemas/NotificationPreferenceCellSourceEnum" + "after": { + "nullable": true } }, "required": [ - "channel", - "enabled", - "feature", - "source" + "after", + "before", + "key", + "section" ] }, - "NotificationPreferenceCellSourceEnum": { - "enum": [ - "default", - "override" - ], - "type": "string", - "description": "* `default` - default\n* `override` - override" - }, - "NotificationPreferenceChange": { + "PackChatTemplate": { "type": "object", "properties": { "feature": { "$ref": "#/components/schemas/Feature4feEnum" }, - "channel": { - "$ref": "#/components/schemas/ChannelB30Enum" + "event": { + "type": "string", + "maxLength": 64 }, - "enabled": { + "text_body": { + "type": "string" + }, + "is_active": { "type": "boolean" } }, "required": [ - "channel", - "enabled", - "feature" + "event", + "feature", + "is_active", + "text_body" ] }, - "NotificationRecipient": { + "PackConfiguration": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true + "modules": { + "type": "array", + "items": { + "type": "string" + } }, - "username": { - "type": "string", - "readOnly": true + "features": { + "type": "array", + "items": { + "type": "string" + } }, - "email": { - "readOnly": true, - "oneOf": [ + "settings": { + "$ref": "#/components/schemas/PackSettings" + }, + "opening_hours": { + "$ref": "#/components/schemas/PackOpeningHours" + }, + "active_waiver": { + "allOf": [ { - "type": "string", - "format": "email" - }, + "$ref": "#/components/schemas/PackMakerspaceWaiver" + } + ], + "nullable": true + }, + "loan_policy": { + "allOf": [ { - "type": "string", - "maxLength": 0 + "$ref": "#/components/schemas/PackMakerspaceLoanPolicy" } - ] + ], + "nullable": true }, - "role": { - "type": "string", - "readOnly": true + "membership_plans": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PackMembershipPlan" + } }, - "receives_notifications": { - "type": "boolean" - } - }, - "required": [ - "email", - "id", - "receives_notifications", - "role", - "username" - ] - }, - "NotificationRecipientUpdate": { - "type": "object", - "properties": { - "id": { - "type": "integer" + "admission_policy": { + "allOf": [ + { + "$ref": "#/components/schemas/PackMakerspaceMembershipSettings" + } + ], + "nullable": true }, - "receives_notifications": { - "type": "boolean" - } - }, - "required": [ - "id", - "receives_notifications" - ] - }, - "NotificationRuleCatalogItem": { - "type": "object", - "properties": { - "stream": { - "type": "string" + "booking_rules": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PackBookingRules" + } }, - "audience": { - "type": "string" + "notification_defaults": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PackNotificationPreference" + } }, - "targets": { + "email_mutes": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/PackEmailNotificationMute" } }, - "events": { + "email_templates": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/PackEmailTemplate" + } + }, + "chat_templates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PackChatTemplate" + } + }, + "categories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PackCategory" + } + }, + "item_templates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PackInventoryProduct" } } }, "required": [ - "audience", - "events", - "stream", - "targets" + "active_waiver", + "admission_policy", + "booking_rules", + "categories", + "chat_templates", + "email_mutes", + "email_templates", + "features", + "item_templates", + "loan_policy", + "membership_plans", + "modules", + "notification_defaults", + "opening_hours", + "settings" ] }, - "NotificationRuleChange": { + "PackEmailNotificationMute": { "type": "object", "properties": { "target": { - "type": "string" + "type": "string", + "maxLength": 32 }, "stream": { - "type": "string" + "type": "string", + "maxLength": 16 }, "event": { - "type": "string" + "type": "string", + "maxLength": 64 }, "audience": { - "type": "string" - }, - "muted": { - "type": "boolean" + "type": "string", + "maxLength": 16 } }, "required": [ "audience", "event", - "muted", "stream", "target" ] }, - "NotificationRuleMute": { + "PackEmailTemplate": { "type": "object", "properties": { - "target": { - "type": "string" - }, "stream": { - "type": "string" + "$ref": "#/components/schemas/StreamEnum" }, - "event": { + "audience": { + "$ref": "#/components/schemas/PackEmailTemplateAudienceEnum" + }, + "key": { + "type": "string", + "maxLength": 32 + }, + "subject": { + "type": "string", + "maxLength": 200 + }, + "text_body": { "type": "string" }, - "audience": { + "html_body": { "type": "string" + }, + "is_active": { + "type": "boolean" } }, "required": [ "audience", - "event", + "html_body", + "is_active", + "key", "stream", - "target" + "subject", + "text_body" ] }, - "NotificationRulesResponse": { + "PackEmailTemplateAudienceEnum": { + "enum": [ + "requester", + "staff" + ], + "type": "string", + "description": "* `requester` - Requester\n* `staff` - Staff" + }, + "PackError": { "type": "object", "properties": { - "catalog": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationRuleCatalogItem" - } + "code": { + "type": "string" }, - "mutes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationRuleMute" - } + "detail": { + "type": "string" }, - "channels": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationChannel" - } + "fields": {}, + "feature": { + "type": "string" }, - "features": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationFeature" - } + "module": { + "type": "string" }, - "preferences": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationPreferenceCell" - } + "purpose": { + "type": "string" } - }, - "required": [ - "catalog", - "channels", - "features", - "mutes", - "preferences" - ] + } }, - "NotificationUnreadCount": { + "PackImport": { "type": "object", "properties": { - "count": { - "type": "integer" + "pack": { + "$ref": "#/components/schemas/SpacePack" + }, + "dry_run": { + "type": "boolean", + "default": true + }, + "base_checksum": { + "type": "string", + "maxLength": 64, + "minLength": 64 } }, "required": [ - "count" - ] - }, - "NullEnum": { - "enum": [ - null + "pack" ] }, - "OccurrenceResult": { + "PackInventoryProduct": { "type": "object", "properties": { - "occurrence": { - "type": "integer" - }, - "starts_at": { + "name": { "type": "string", - "format": "date-time" + "maxLength": 200 }, - "ends_at": { + "category": { "type": "string", - "format": "date-time" + "nullable": true }, - "booking_id": { + "description": { + "type": "string" + }, + "tracking_mode": { + "$ref": "#/components/schemas/TrackingModeB86Enum" + }, + "loan_days": { "type": "integer", + "maximum": 365, + "minimum": 1, "nullable": true }, - "status": { - "type": "string" + "max_renewals": { + "type": "integer", + "maximum": 100, + "minimum": 0, + "nullable": true }, - "detail": { - "type": "string" + "is_public": { + "type": "boolean" + }, + "public_self_checkout_enabled": { + "type": "boolean" + }, + "show_public_count": { + "type": "boolean" + }, + "public_availability_mode": { + "$ref": "#/components/schemas/PublicAvailabilityModeFc2Enum" } }, "required": [ - "booking_id", - "detail", - "ends_at", - "occurrence", - "starts_at", - "status" + "category", + "description", + "is_public", + "loan_days", + "max_renewals", + "name", + "public_availability_mode", + "public_self_checkout_enabled", + "show_public_count", + "tracking_mode" ] }, - "OfflineCheckInOperation": { + "PackMakerspaceLoanPolicy": { "type": "object", "properties": { - "operation_id": { - "type": "string", - "format": "uuid" + "default_loan_days": { + "type": "integer", + "maximum": 365, + "minimum": 1 }, - "checkin_token": { - "type": "string", - "maxLength": 64 + "loan_renewal_mode": { + "$ref": "#/components/schemas/LoanRenewalModeEnum" }, - "reported_occurred_at": { - "type": "string", - "format": "date-time" + "loan_max_renewals": { + "type": "integer", + "maximum": 100, + "minimum": 0 + }, + "loan_renewal_days": { + "type": "integer", + "maximum": 365, + "minimum": 1 + }, + "loan_max_active": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "loan_max_items": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "loan_reminder_offsets_hours": {}, + "loan_escalate_overdue_days": { + "type": "integer", + "maximum": 365, + "minimum": 0 } }, "required": [ - "checkin_token", - "operation_id", - "reported_occurred_at" + "default_loan_days", + "loan_escalate_overdue_days", + "loan_max_active", + "loan_max_items", + "loan_max_renewals", + "loan_reminder_offsets_hours", + "loan_renewal_days", + "loan_renewal_mode" ] }, - "OfflineCheckInResult": { + "PackMakerspaceMembershipSettings": { "type": "object", "properties": { - "operation_id": { - "type": "string", - "format": "uuid" + "join_mode": { + "$ref": "#/components/schemas/JoinModeEnum" }, - "outcome": { - "$ref": "#/components/schemas/OfflineCheckInResultOutcomeEnum" + "vouches_required": { + "type": "integer", + "maximum": 32767, + "minimum": 0 }, - "registration_id": { - "type": "integer" + "voucher_min_tenure_days": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 }, - "attended_at": { + "voucher_pending_limit": { + "type": "integer", + "maximum": 32767, + "minimum": 0 + }, + "vouch_expiry_days": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "vouch_replaces_approval": { + "type": "boolean" + }, + "payment_timing": { + "$ref": "#/components/schemas/PaymentTimingEnum" + }, + "application_schema": { + "nullable": true + }, + "default_plan": { "type": "string", - "format": "date-time" + "nullable": true + }, + "joining_fee": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "joining_fee_currency": { + "type": "string", + "maxLength": 3 + }, + "rejoin_dues_rule": { + "$ref": "#/components/schemas/RejoinDuesRuleEnum" + }, + "request_expiry_days": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 } }, "required": [ - "operation_id", - "outcome" + "application_schema", + "default_plan", + "join_mode", + "joining_fee", + "joining_fee_currency", + "payment_timing", + "rejoin_dues_rule", + "request_expiry_days", + "vouch_expiry_days", + "vouch_replaces_approval", + "voucher_min_tenure_days", + "voucher_pending_limit", + "vouches_required" ] }, - "OfflineCheckInResultOutcomeEnum": { - "enum": [ - "applied", - "duplicate_operation", - "already_attended", - "registration_changed", - "event_unavailable", - "invalid_token", - "outside_window" - ], - "type": "string", - "description": "* `applied` - applied\n* `duplicate_operation` - duplicate_operation\n* `already_attended` - already_attended\n* `registration_changed` - registration_changed\n* `event_unavailable` - event_unavailable\n* `invalid_token` - invalid_token\n* `outside_window` - outside_window" - }, - "OfflineCheckInSyncRequest": { + "PackMakerspaceWaiver": { "type": "object", "properties": { - "lease_token": { + "version": { "type": "string", - "maxLength": 8192 + "maxLength": 64 }, - "operations": { - "type": "array", - "items": { - "$ref": "#/components/schemas/OfflineCheckInOperation" - } + "body": { + "type": "string" } }, "required": [ - "lease_token", - "operations" + "body", + "version" ] }, - "OfflineCheckInSyncResponse": { + "PackMembershipPlan": { "type": "object", "properties": { - "recorded_at": { + "name": { "type": "string", - "format": "date-time" + "maxLength": 120 }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/OfflineCheckInResult" - } + "interval": { + "$ref": "#/components/schemas/IntervalEnum" + }, + "custom_days": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0, + "nullable": true + }, + "amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "currency": { + "type": "string", + "maxLength": 3 + }, + "is_active": { + "type": "boolean" + }, + "loan_max_active": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0, + "nullable": true + }, + "loan_max_items": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0, + "nullable": true } }, "required": [ - "recorded_at", - "results" + "amount", + "currency", + "custom_days", + "interval", + "is_active", + "loan_max_active", + "loan_max_items", + "name" ] }, - "OfflineRosterEvent": { + "PackNotificationPreference": { "type": "object", "properties": { - "id": { - "type": "integer" - }, - "title": { - "type": "string" + "feature": { + "$ref": "#/components/schemas/Feature4feEnum" }, - "starts_at": { - "type": "string", - "format": "date-time" + "channel": { + "$ref": "#/components/schemas/Channel292Enum" }, - "ends_at": { - "type": "string", - "format": "date-time" + "enabled": { + "type": "boolean" } }, "required": [ - "ends_at", - "id", - "starts_at", - "title" + "channel", + "enabled", + "feature" ] }, - "OfflineRosterRegistration": { + "PackOpeningHours": { "type": "object", "properties": { - "registration_id": { - "type": "integer" - }, - "checkin_token": { - "type": "string", - "format": "uuid" - }, - "name": { + "timezone": { "type": "string" }, - "host_waiver_state": { - "$ref": "#/components/schemas/HostWaiverStateEnum" + "opening_hours": { + "oneOf": [ + { + "type": "object", + "maxProperties": 0 + }, + { + "type": "object", + "required": [ + "weekly", + "exceptions" + ], + "additionalProperties": false, + "properties": { + "weekly": { + "oneOf": [ + { + "type": "object", + "maxProperties": 0 + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ], + "properties": { + "mon": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "tue": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "wed": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "thu": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "fri": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "sat": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "sun": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + } + } + } + ] + }, + "exceptions": { + "type": "object", + "maxProperties": 366, + "additionalProperties": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "description": "YYYY-MM-DD keys; each value replaces that date's weekly hours." + } + } + } + ], + "description": "Empty object means always open. Empty weekly hours mean always open except dated overrides. Empty interval lists mean closed. Sorted intervals use local wall time; split overnight hours across days." } }, "required": [ - "checkin_token", - "host_waiver_state", - "name", - "registration_id" + "opening_hours", + "timezone" ] }, - "OfflineRosterResponse": { + "PackPreview": { "type": "object", "properties": { - "lease_token": { + "base_checksum": { "type": "string" }, - "lease_id": { - "type": "string", - "format": "uuid" - }, - "server_time": { - "type": "string", - "format": "date-time" - }, - "issued_at": { - "type": "string", - "format": "date-time" - }, - "expires_at": { - "type": "string", - "format": "date-time" - }, - "scan_opens_at": { - "type": "string", - "format": "date-time" - }, - "scan_closes_at": { - "type": "string", - "format": "date-time" - }, - "sync_deadline": { - "type": "string", - "format": "date-time" - }, - "event": { - "$ref": "#/components/schemas/OfflineRosterEvent" + "applied": { + "type": "boolean" }, - "registrations": { + "changes": { "type": "array", "items": { - "$ref": "#/components/schemas/OfflineRosterRegistration" + "$ref": "#/components/schemas/PackChange" } } }, "required": [ - "event", - "expires_at", - "issued_at", - "lease_id", - "lease_token", - "registrations", - "scan_closes_at", - "scan_opens_at", - "server_time", - "sync_deadline" + "applied", + "base_checksum", + "changes" ] }, - "OidcBrowserCallback": { + "PackProfile": { "type": "object", "properties": { - "code": { + "id": { + "type": "integer", + "readOnly": true + }, + "name": { "type": "string", - "maxLength": 4096 + "maxLength": 120 }, - "state": { + "pack": { + "$ref": "#/components/schemas/SpacePack" + }, + "created_at": { "type": "string", - "maxLength": 512 + "format": "date-time", + "readOnly": true }, - "nonce": { + "updated_at": { "type": "string", - "maxLength": 512 + "format": "date-time", + "readOnly": true } }, "required": [ - "code", - "nonce", - "state" + "created_at", + "id", + "name", + "pack", + "updated_at" ] }, - "OidcBrowserLoginResponse": { + "PackSettings": { "type": "object", + "description": "Schema only: actual writes validate through MakerspaceSerializer.", "properties": { - "access": { - "type": "string" + "public_inventory_enabled": { + "type": "boolean" }, - "user": { - "type": "object", - "additionalProperties": {} + "public_site_enabled": { + "type": "boolean" }, - "outcome": { - "type": "string" - } - }, - "required": [ - "access", - "outcome", - "user" - ] - }, - "OidcBrowserStart": { - "type": "object", - "properties": { - "redirect_uri": { - "type": "string", - "format": "uri", - "maxLength": 2048 + "public_landing_page": { + "$ref": "#/components/schemas/PublicLandingPage6a0Enum" }, - "email": { - "oneOf": [ - { - "type": "string", - "format": "email" - }, - { - "type": "string", - "maxLength": 0 - } - ] + "public_stats_enabled": { + "type": "boolean" }, - "makerspace_slug": { - "oneOf": [ - { - "type": "string", - "pattern": "^[-a-zA-Z0-9_]+$" - }, - { - "type": "string", - "maxLength": 0 - } - ] - } - }, - "required": [ - "redirect_uri" - ] - }, - "OidcBrowserStartResponse": { - "type": "object", - "properties": { - "authorization_url": { + "public_stats_show_holder_names": { + "type": "boolean" + }, + "public_print_status_lookup_policy": { + "$ref": "#/components/schemas/PublicPrintStatusLookupPolicyEnum" + }, + "membership_policy": { + "$ref": "#/components/schemas/MembershipPolicyEnum" + }, + "membership_dues_amount": { "type": "string", - "format": "uri" + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "state": { - "type": "string" + "referrals_enabled": { + "type": "boolean" }, - "nonce": { - "type": "string" + "lapsed_members_cannot_borrow": { + "type": "boolean" }, - "expires_in": { - "type": "integer" - } + "staff_notifications_enabled": { + "type": "boolean" + }, + "booking_requester_notifications_enabled": { + "type": "boolean" + }, + "default_loan_days": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "presence_preset_minutes": {} }, "required": [ - "authorization_url", - "expires_in", - "nonce", - "state" + "booking_requester_notifications_enabled", + "default_loan_days", + "lapsed_members_cannot_borrow", + "membership_dues_amount", + "membership_policy", + "presence_preset_minutes", + "public_inventory_enabled", + "public_landing_page", + "public_print_status_lookup_policy", + "public_site_enabled", + "public_stats_enabled", + "public_stats_show_holder_names", + "referrals_enabled", + "staff_notifications_enabled" ] }, - "OperatorCandidate": { + "PaginatedAdminRequestList": { "type": "object", + "required": [ + "count", + "results" + ], "properties": { - "user_id": { + "count": { "type": "integer", - "readOnly": true + "example": 123 }, - "username": { + "next": { "type": "string", - "readOnly": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, - "display_name": { + "previous": { "type": "string", - "readOnly": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AdminRequest" + } } - }, - "required": [ - "display_name", - "user_id", - "username" - ] - }, - "OperatorEnum": { - "enum": [ - "gt", - "gte", - "lt", - "lte", - "eq" - ], - "type": "string", - "description": "* `gt` - gt\n* `gte` - gte\n* `lt` - lt\n* `lte` - lte\n* `eq` - eq" + } }, - "OrganizationCreate": { + "PaginatedApiClientList": { "type": "object", + "required": [ + "count", + "results" + ], "properties": { - "name": { - "type": "string", - "maxLength": 200 + "count": { + "type": "integer", + "example": 123 }, - "slug": { + "next": { "type": "string", - "maxLength": 50, - "pattern": "^[-a-zA-Z0-9_]+$" - }, - "description": { - "type": "string" + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, - "website": { - "oneOf": [ - { - "type": "string", - "format": "uri", - "maxLength": 200 - }, - { - "type": "string", - "maxLength": 0 - } - ] + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" }, - "public_profile_enabled": { - "type": "boolean" + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiClient" + } } - }, - "required": [ - "name", - "slug" - ] + } }, - "OrganizationDetail": { + "PaginatedApiKeyRequestList": { "type": "object", + "required": [ + "count", + "results" + ], "properties": { - "id": { + "count": { "type": "integer", - "readOnly": true + "example": 123 }, - "slug": { + "next": { "type": "string", - "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, - "name": { + "previous": { "type": "string", - "readOnly": true - }, - "governance_actions": { - "type": "array", - "items": { - "type": "string" - }, - "readOnly": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" }, - "granted_actions": { + "results": { "type": "array", "items": { - "type": "string" - }, - "readOnly": true - }, - "description": { - "type": "string", - "readOnly": true + "$ref": "#/components/schemas/ApiKeyRequest" + } + } + } + }, + "PaginatedAuditLogList": { + "type": "object", + "required": [ + "count", + "results" + ], + "properties": { + "count": { + "type": "integer", + "example": 123 }, - "website": { + "next": { "type": "string", + "nullable": true, "format": "uri", - "readOnly": true + "example": "http://api.example.org/accounts/?page=4" }, - "logo_url": { + "previous": { "type": "string", - "format": "uri", "nullable": true, - "readOnly": true - }, - "public_profile_enabled": { - "type": "boolean", - "readOnly": true - }, - "is_active": { - "type": "boolean", - "readOnly": true - }, - "legal_name": { - "type": "string", - "readOnly": true - }, - "registration_number": { - "type": "string", - "readOnly": true - }, - "contact_email": { - "type": "string", - "format": "email", - "readOnly": true + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" }, - "billing_email": { - "type": "string", - "format": "email", - "readOnly": true + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuditLog" + } } - }, - "required": [ - "billing_email", - "contact_email", - "description", - "governance_actions", - "granted_actions", - "id", - "is_active", - "legal_name", - "logo_url", - "name", - "public_profile_enabled", - "registration_number", - "slug", - "website" - ] + } }, - "OrganizationEventHost": { + "PaginatedBindingList": { "type": "object", + "required": [ + "count", + "results" + ], "properties": { - "slug": { - "type": "string", - "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" + "count": { + "type": "integer", + "example": 123 }, - "name": { + "next": { "type": "string", - "readOnly": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, - "logo_url": { + "previous": { "type": "string", - "format": "uri", "nullable": true, - "readOnly": true + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Binding" + } } - }, - "required": [ - "logo_url", - "name", - "slug" - ] + } }, - "OrganizationInvitation": { + "PaginatedBoxList": { "type": "object", + "required": [ + "count", + "results" + ], "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "organization_id": { - "type": "integer", - "readOnly": true - }, - "governance_actions": { - "readOnly": true - }, - "granted_actions": { - "readOnly": true - }, - "expires_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "redeemed_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "revoked_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "created_by_id": { + "count": { "type": "integer", - "readOnly": true, - "nullable": true + "example": 123 }, - "redeemed_by_id": { - "type": "integer", - "readOnly": true, - "nullable": true + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, - "created_at": { + "previous": { "type": "string", - "format": "date-time", - "readOnly": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" }, - "state": { - "allOf": [ - { - "$ref": "#/components/schemas/State31eEnum" - } - ], - "readOnly": true + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Box" + } } - }, - "required": [ - "created_at", - "created_by_id", - "expires_at", - "governance_actions", - "granted_actions", - "id", - "organization_id", - "redeemed_at", - "redeemed_by_id", - "revoked_at", - "state" - ] + } }, - "OrganizationInvitationCreate": { + "PaginatedCategoryAdminList": { "type": "object", + "required": [ + "count", + "results" + ], "properties": { - "governance_actions": { - "type": "array", - "items": { - "type": "string" - } + "count": { + "type": "integer", + "example": 123 }, - "granted_actions": { + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" + }, + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" + }, + "results": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/CategoryAdmin" } - }, - "expires_in_days": { - "type": "integer", - "maximum": 30, - "minimum": 1, - "default": 7 } } }, - "OrganizationInvitationCreated": { + "PaginatedDeviceList": { "type": "object", + "required": [ + "count", + "results" + ], "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "organization_id": { + "count": { "type": "integer", - "readOnly": true - }, - "governance_actions": { - "readOnly": true - }, - "granted_actions": { - "readOnly": true - }, - "expires_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "example": 123 }, - "redeemed_at": { + "next": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, - "revoked_at": { + "previous": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "created_by_id": { - "type": "integer", - "readOnly": true, - "nullable": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" }, - "redeemed_by_id": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Device" + } + } + } + }, + "PaginatedDirectLoanList": { + "type": "object", + "required": [ + "count", + "results" + ], + "properties": { + "count": { "type": "integer", - "readOnly": true, - "nullable": true + "example": 123 }, - "created_at": { + "next": { "type": "string", - "format": "date-time", - "readOnly": true - }, - "state": { - "allOf": [ - { - "$ref": "#/components/schemas/State31eEnum" - } - ], - "readOnly": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, - "token": { + "previous": { "type": "string", - "readOnly": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" }, - "redeem_path": { - "type": "string", - "readOnly": true + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DirectLoan" + } } - }, - "required": [ - "created_at", - "created_by_id", - "expires_at", - "governance_actions", - "granted_actions", - "id", - "organization_id", - "redeem_path", - "redeemed_at", - "redeemed_by_id", - "revoked_at", - "state", - "token" - ] + } }, - "OrganizationInvitationList": { + "PaginatedDirectLoanMemberList": { "type": "object", + "required": [ + "count", + "results" + ], "properties": { "count": { - "type": "integer" + "type": "integer", + "example": 123 }, "next": { "type": "string", - "nullable": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", - "nullable": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { - "$ref": "#/components/schemas/OrganizationInvitation" + "$ref": "#/components/schemas/DirectLoanMember" } } - }, + } + }, + "PaginatedEmailLogList": { + "type": "object", "required": [ "count", - "next", - "previous", "results" - ] - }, - "OrganizationInvitationRedeem": { - "type": "object", + ], "properties": { - "token": { + "count": { + "type": "integer", + "example": 123 + }, + "next": { "type": "string", - "maxLength": 200, - "minLength": 20 + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" + }, + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EmailLog" + } } - }, - "required": [ - "token" - ] + } }, - "OrganizationInvitationRedeemed": { + "PaginatedInventoryAssetAdminList": { "type": "object", + "required": [ + "count", + "results" + ], "properties": { - "user": { - "$ref": "#/components/schemas/AuthUserPayload" + "count": { + "type": "integer", + "example": 123 }, - "membership": { - "$ref": "#/components/schemas/OrganizationMembership" + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" + }, + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InventoryAssetAdmin" + } } - }, - "required": [ - "membership", - "user" - ] + } }, - "OrganizationLink": { + "PaginatedInventoryProductAdminList": { "type": "object", + "required": [ + "count", + "results" + ], "properties": { - "id": { + "count": { "type": "integer", - "readOnly": true + "example": 123 }, - "makerspace_id": { - "type": "integer", - "readOnly": true + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, - "makerspace_name": { + "previous": { "type": "string", - "readOnly": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" }, - "relationship": { - "$ref": "#/components/schemas/RelationshipEnum" + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InventoryProductAdmin" + } } - }, - "required": [ - "id", - "makerspace_id", - "makerspace_name", - "relationship" - ] + } }, - "OrganizationLinkPage": { + "PaginatedJobList": { "type": "object", + "required": [ + "count", + "results" + ], "properties": { "count": { - "type": "integer" + "type": "integer", + "example": 123 }, "next": { "type": "string", + "nullable": true, "format": "uri", - "nullable": true + "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", + "nullable": true, "format": "uri", - "nullable": true + "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { - "$ref": "#/components/schemas/OrganizationLink" + "$ref": "#/components/schemas/Job" } } - }, + } + }, + "PaginatedLabelCommandList": { + "type": "object", "required": [ "count", - "next", - "previous", "results" - ] - }, - "OrganizationLinkWrite": { - "type": "object", + ], "properties": { - "makerspace_id": { + "count": { "type": "integer", - "minimum": 1 + "example": 123 }, - "relationship": { - "$ref": "#/components/schemas/RelationshipEnum" + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" + }, + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LabelCommand" + } } - }, - "required": [ - "makerspace_id", - "relationship" - ] + } }, - "OrganizationList": { + "PaginatedMaterialList": { "type": "object", + "required": [ + "count", + "results" + ], "properties": { "count": { - "type": "integer" + "type": "integer", + "example": 123 }, "next": { "type": "string", - "nullable": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", - "nullable": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { - "$ref": "#/components/schemas/OrganizationSummary" + "$ref": "#/components/schemas/Material" } } - }, + } + }, + "PaginatedNotificationList": { + "type": "object", "required": [ "count", - "next", - "previous", "results" - ] - }, - "OrganizationMembership": { - "type": "object", + ], "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "user_id": { + "count": { "type": "integer", - "readOnly": true - }, - "username": { - "type": "string", - "readOnly": true + "example": 123 }, - "display_name": { + "next": { "type": "string", - "readOnly": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, - "email": { + "previous": { "type": "string", - "format": "email", - "readOnly": true - }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/OrganizationMembershipStatusEnum" - } - ], - "readOnly": true - }, - "governance_actions": { - "readOnly": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" }, - "granted_actions": { - "readOnly": true + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Notification" + } + } + } + }, + "PaginatedOverdueScheduleList": { + "type": "object", + "required": [ + "count", + "results" + ], + "properties": { + "count": { + "type": "integer", + "example": 123 }, - "created_at": { + "next": { "type": "string", - "format": "date-time", - "readOnly": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, - "updated_at": { + "previous": { "type": "string", - "format": "date-time", - "readOnly": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OverdueSchedule" + } } - }, - "required": [ - "created_at", - "display_name", - "email", - "governance_actions", - "granted_actions", - "id", - "status", - "updated_at", - "user_id", - "username" - ] + } }, - "OrganizationMembershipList": { + "PaginatedPackProfileList": { "type": "object", + "required": [ + "count", + "results" + ], "properties": { "count": { - "type": "integer" + "type": "integer", + "example": 123 }, "next": { "type": "string", - "nullable": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", - "nullable": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { - "$ref": "#/components/schemas/OrganizationMembership" + "$ref": "#/components/schemas/PackProfile" } } - }, + } + }, + "PaginatedPublicMachineDetailList": { + "type": "object", "required": [ "count", - "next", - "previous", "results" - ] - }, - "OrganizationMembershipStatusEnum": { - "enum": [ - "active", - "suspended" ], - "type": "string", - "description": "* `active` - Active\n* `suspended` - Suspended" - }, - "OrganizationReportBreakdown": { - "type": "object", "properties": { - "rows": { + "count": { + "type": "integer", + "example": 123 + }, + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" + }, + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" + }, + "results": { "type": "array", "items": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/PublicMachineDetail" } - }, - "makerspace_id": { - "type": "integer" } - }, - "required": [ - "makerspace_id", - "rows" - ] + } }, - "OrganizationReportResponse": { + "PaginatedPublicMachineList": { "type": "object", + "required": [ + "count", + "results" + ], "properties": { - "report_key": { - "type": "string" + "count": { + "type": "integer", + "example": 123 }, - "strategy": { - "$ref": "#/components/schemas/StrategyEnum" + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, - "breakdown": { + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" + }, + "results": { "type": "array", "items": { - "$ref": "#/components/schemas/OrganizationReportBreakdown" + "$ref": "#/components/schemas/PublicMachine" } - }, - "total": { - "$ref": "#/components/schemas/OrganizationReportRows" } - }, - "required": [ - "breakdown", - "report_key", - "strategy", - "total" - ] + } }, - "OrganizationReportRows": { + "PaginatedPublicProductList": { "type": "object", + "required": [ + "count", + "results" + ], "properties": { - "rows": { + "count": { + "type": "integer", + "example": 123 + }, + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" + }, + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" + }, + "results": { "type": "array", "items": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/PublicProduct" } } - }, - "required": [ - "rows" - ] + } }, - "OrganizationSummary": { + "PaginatedQrPrintBatchList": { "type": "object", + "required": [ + "count", + "results" + ], "properties": { - "id": { + "count": { "type": "integer", - "readOnly": true + "example": 123 }, - "slug": { + "next": { "type": "string", - "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, - "name": { + "previous": { "type": "string", - "readOnly": true - }, - "governance_actions": { - "type": "array", - "items": { - "type": "string" - }, - "readOnly": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" }, - "granted_actions": { + "results": { "type": "array", "items": { - "type": "string" - }, - "readOnly": true + "$ref": "#/components/schemas/QrPrintBatch" + } } - }, - "required": [ - "governance_actions", - "granted_actions", - "id", - "name", - "slug" - ] - }, - "OrientationEnum": { - "enum": [ - "portrait", - "landscape" - ], - "type": "string", - "description": "* `portrait` - portrait\n* `landscape` - landscape" + } }, - "OtpResetPasswordConfirm": { + "PaginatedReorderSuggestionList": { "type": "object", + "required": [ + "count", + "results" + ], "properties": { - "email": { - "type": "string", - "format": "email" + "count": { + "type": "integer", + "example": 123 }, - "code": { + "next": { "type": "string", - "writeOnly": true, - "pattern": "^\\d{6}$" + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, - "new_password": { + "previous": { "type": "string", - "writeOnly": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReorderSuggestion" + } } - }, - "required": [ - "code", - "email", - "new_password" - ] - }, - "OutcomeA1aEnum": { - "enum": [ - "succeeded", - "retryable_failed", - "failed" - ], - "type": "string", - "description": "* `succeeded` - succeeded\n* `retryable_failed` - retryable_failed\n* `failed` - failed" + } }, - "OverdueSchedule": { + "PaginatedReplayRecoveryList": { "type": "object", + "required": [ + "count", + "results" + ], "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "machine_id": { + "count": { "type": "integer", - "readOnly": true + "example": 123 }, - "machine_name": { + "next": { "type": "string", - "readOnly": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, - "description": { + "previous": { "type": "string", - "readOnly": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" }, - "next_due": { - "type": "string", - "format": "date", - "readOnly": true + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReplayRecovery" + } } - }, - "required": [ - "description", - "id", - "machine_id", - "machine_name", - "next_due" - ] + } }, - "OwnMemberCard": { + "PaginatedReportScheduleList": { "type": "object", - "description": "Never carries the photo key or a URL that outlives the response.", + "required": [ + "count", + "results" + ], "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "card_number": { + "count": { "type": "integer", - "readOnly": true + "example": 123 }, - "printed_name": { + "next": { "type": "string", - "readOnly": true - }, - "membership_id": { - "type": "integer", - "readOnly": true, - "nullable": true - }, - "is_active": { - "type": "boolean", - "readOnly": true - }, - "photo_set": { - "type": "boolean", - "readOnly": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, - "photo_consent_at": { + "previous": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "qr_active": { - "type": "boolean", - "readOnly": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" }, - "print_count": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReportSchedule" + } + } + } + }, + "PaginatedScheduleList": { + "type": "object", + "required": [ + "count", + "results" + ], + "properties": { + "count": { "type": "integer", - "readOnly": true - }, - "last_printed_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "issued_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "revoked_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "revoked_reason": { - "type": "string", - "readOnly": true - }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "example": 123 }, - "updated_at": { + "next": { "type": "string", - "format": "date-time", - "readOnly": true + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, - "qr_image": { + "previous": { "type": "string", "nullable": true, - "readOnly": true + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Schedule" + } } - }, - "required": [ - "card_number", - "created_at", - "id", - "is_active", - "issued_at", - "last_printed_at", - "membership_id", - "photo_consent_at", - "photo_set", - "print_count", - "printed_name", - "qr_active", - "qr_image", - "revoked_at", - "revoked_reason", - "updated_at" - ] + } }, - "PackBookingRules": { + "PaginatedStaffInvitationList": { "type": "object", - "description": "Query-free shape: bookings may be tombstoned on this deployment.", + "required": [ + "count", + "results" + ], "properties": { - "name": { - "type": "string", - "maxLength": 200 - }, - "min_booking_duration_minutes": { - "type": "integer", - "minimum": 1 - }, - "max_booking_duration_minutes": { + "count": { "type": "integer", - "minimum": 1 + "example": 123 }, - "booking_lead_time_minutes": { - "type": "integer", - "minimum": 0 + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, - "max_booking_advance_days": { - "type": "integer", - "minimum": 1 + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" }, - "approval_mode": { - "$ref": "#/components/schemas/PackBookingRulesApprovalModeEnum" + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StaffInvitation" + } } - }, - "required": [ - "approval_mode", - "booking_lead_time_minutes", - "max_booking_advance_days", - "max_booking_duration_minutes", - "min_booking_duration_minutes", - "name" - ] - }, - "PackBookingRulesApprovalModeEnum": { - "enum": [ - "instant", - "approve" - ], - "type": "string", - "description": "* `instant` - instant\n* `approve` - approve" + } }, - "PackCategory": { + "PaginatedStaffMembershipList": { "type": "object", + "required": [ + "count", + "results" + ], "properties": { - "slug": { - "type": "string", - "maxLength": 120, - "pattern": "^[-a-zA-Z0-9_]+$" - }, - "name": { - "type": "string", - "maxLength": 100 - }, - "display_order": { + "count": { "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "example": 123 }, - "icon": { + "next": { "type": "string", - "maxLength": 50 + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, - "loan_days": { - "type": "integer", - "maximum": 365, - "minimum": 1, - "nullable": true + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" }, - "max_renewals": { - "type": "integer", - "maximum": 100, - "minimum": 0, - "nullable": true + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StaffMembership" + } } - }, - "required": [ - "display_order", - "icon", - "loan_days", - "max_renewals", - "name", - "slug" - ] + } }, - "PackChange": { + "PaginatedStockTransferList": { "type": "object", + "required": [ + "count", + "results" + ], "properties": { - "section": { - "type": "string" + "count": { + "type": "integer", + "example": 123 }, - "key": { - "type": "string" + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, - "before": { - "nullable": true + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" }, - "after": { - "nullable": true + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StockTransfer" + } } - }, - "required": [ - "after", - "before", - "key", - "section" - ] + } }, - "PackChatTemplate": { + "PaginatedStocktakeList": { "type": "object", + "required": [ + "count", + "results" + ], "properties": { - "feature": { - "$ref": "#/components/schemas/Feature4feEnum" + "count": { + "type": "integer", + "example": 123 }, - "event": { + "next": { "type": "string", - "maxLength": 64 + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, - "text_body": { - "type": "string" + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" }, - "is_active": { - "type": "boolean" + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Stocktake" + } } - }, - "required": [ - "event", - "feature", - "is_active", - "text_body" - ] + } }, - "PackConfiguration": { + "PaginatedSubdomainRequestList": { "type": "object", + "required": [ + "count", + "results" + ], "properties": { - "modules": { - "type": "array", - "items": { - "type": "string" - } - }, - "features": { - "type": "array", - "items": { - "type": "string" - } + "count": { + "type": "integer", + "example": 123 }, - "settings": { - "$ref": "#/components/schemas/PackSettings" + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, - "opening_hours": { - "$ref": "#/components/schemas/PackOpeningHours" + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" }, - "roles": { + "results": { "type": "array", "items": { - "$ref": "#/components/schemas/PackMakerspaceRole" + "$ref": "#/components/schemas/SubdomainRequest" } + } + } + }, + "PaginatedWarrantyReportRowList": { + "type": "object", + "required": [ + "count", + "results" + ], + "properties": { + "count": { + "type": "integer", + "example": 123 }, - "active_waiver": { - "allOf": [ - { - "$ref": "#/components/schemas/PackMakerspaceWaiver" - } - ], - "nullable": true + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" }, - "loan_policy": { - "allOf": [ - { - "$ref": "#/components/schemas/PackMakerspaceLoanPolicy" - } - ], - "nullable": true + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" }, - "membership_plans": { + "results": { "type": "array", "items": { - "$ref": "#/components/schemas/PackMembershipPlan" + "$ref": "#/components/schemas/WarrantyReportRow" } + } + } + }, + "Pairing": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "readOnly": true }, - "admission_policy": { - "allOf": [ - { - "$ref": "#/components/schemas/PackMakerspaceMembershipSettings" - } - ], - "nullable": true + "migration_id": { + "type": "string", + "format": "uuid" }, - "booking_rules": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PackBookingRules" - } + "source_tenant_id": { + "type": "string", + "maxLength": 64 }, - "notification_defaults": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PackNotificationPreference" - } + "archive_digest": { + "type": "string", + "maxLength": 64 }, - "email_mutes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PackEmailNotificationMute" - } + "source_deployment_id": { + "type": "string", + "maxLength": 128 }, - "email_templates": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PackEmailTemplate" - } + "source_fingerprint": { + "type": "string", + "maxLength": 64 }, - "chat_templates": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PackChatTemplate" - } + "target_deployment_id": { + "type": "string", + "maxLength": 128 }, - "categories": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PackCategory" - } + "target_fingerprint": { + "type": "string", + "maxLength": 64 }, - "item_templates": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PackInventoryProduct" - } + "approved_at": { + "type": "string", + "format": "date-time", + "readOnly": true } }, "required": [ - "active_waiver", - "admission_policy", - "booking_rules", - "categories", - "chat_templates", - "email_mutes", - "email_templates", - "features", - "item_templates", - "loan_policy", - "membership_plans", - "modules", - "notification_defaults", - "opening_hours", - "roles", - "settings" + "approved_at", + "archive_digest", + "id", + "migration_id", + "source_deployment_id", + "source_fingerprint", + "source_tenant_id", + "target_deployment_id", + "target_fingerprint" ] }, - "PackEmailNotificationMute": { + "PairingCreate": { "type": "object", "properties": { - "target": { - "type": "string", - "maxLength": 32 - }, - "stream": { + "migration_id": { "type": "string", - "maxLength": 16 + "format": "uuid" }, - "event": { + "source_tenant_id": { "type": "string", "maxLength": 64 }, - "audience": { + "archive_digest": { "type": "string", - "maxLength": 16 + "pattern": "^[0-9a-f]{64}$" + }, + "source": { + "type": "object", + "additionalProperties": {} + }, + "target": { + "type": "object", + "additionalProperties": {} } }, "required": [ - "audience", - "event", - "stream", + "archive_digest", + "migration_id", + "source", + "source_tenant_id", "target" ] }, - "PackEmailTemplate": { + "PaperSizeEnum": { + "enum": [ + "A4", + "LETTER", + "custom" + ], + "type": "string", + "description": "* `A4` - A4\n* `LETTER` - LETTER\n* `custom` - custom" + }, + "ParticipatingOrganization": { "type": "object", "properties": { - "stream": { - "$ref": "#/components/schemas/StreamEnum" - }, - "audience": { - "$ref": "#/components/schemas/PackEmailTemplateAudienceEnum" - }, - "key": { - "type": "string", - "maxLength": 32 + "id": { + "type": "integer", + "readOnly": true }, - "subject": { + "name": { "type": "string", - "maxLength": 200 - }, - "text_body": { - "type": "string" - }, - "html_body": { - "type": "string" - }, - "is_active": { - "type": "boolean" + "readOnly": true } }, "required": [ - "audience", - "html_body", - "is_active", - "key", - "stream", - "subject", - "text_body" + "id", + "name" ] }, - "PackEmailTemplateAudienceEnum": { + "PartitionEnum": { "enum": [ - "requester", - "staff" + "tenant", + "currency", + "unit" ], "type": "string", - "description": "* `requester` - Requester\n* `staff` - Staff" + "description": "* `tenant` - tenant\n* `currency` - currency\n* `unit` - unit" }, - "PackError": { + "PasswordLoginResult": { + "oneOf": [ + { + "$ref": "#/components/schemas/LoginResponse" + }, + { + "$ref": "#/components/schemas/MFAChallengeResponse" + } + ] + }, + "PasswordResetAcknowledgement": { "type": "object", "properties": { - "code": { - "type": "string" - }, "detail": { "type": "string" - }, - "fields": {}, - "feature": { - "type": "string" - }, - "module": { - "type": "string" - }, - "purpose": { - "type": "string" } - } + }, + "required": [ + "detail" + ] }, - "PackImport": { + "PasswordResetFailure": { "type": "object", "properties": { - "pack": { - "$ref": "#/components/schemas/SpacePack" - }, - "dry_run": { - "type": "boolean", - "default": true - }, - "base_checksum": { - "type": "string", - "maxLength": 64, - "minLength": 64 + "detail": { + "type": "string" } }, "required": [ - "pack" + "detail" ] }, - "PackInventoryProduct": { + "PasswordUpdated": { "type": "object", "properties": { - "name": { - "type": "string", - "maxLength": 200 - }, - "category": { - "type": "string", - "nullable": true - }, - "description": { + "detail": { "type": "string" - }, - "tracking_mode": { - "$ref": "#/components/schemas/TrackingModeB86Enum" - }, - "loan_days": { - "type": "integer", - "maximum": 365, - "minimum": 1, - "nullable": true - }, - "max_renewals": { - "type": "integer", - "maximum": 100, - "minimum": 0, - "nullable": true - }, - "is_public": { - "type": "boolean" - }, - "public_self_checkout_enabled": { - "type": "boolean" - }, - "show_public_count": { - "type": "boolean" - }, - "public_availability_mode": { - "$ref": "#/components/schemas/PublicAvailabilityModeFc2Enum" } }, "required": [ - "category", - "description", - "is_public", - "loan_days", - "max_renewals", - "name", - "public_availability_mode", - "public_self_checkout_enabled", - "show_public_count", - "tracking_mode" + "detail" ] }, - "PackMakerspaceLoanPolicy": { + "PasswordValidationFailure": { "type": "object", "properties": { - "default_loan_days": { - "type": "integer", - "maximum": 365, - "minimum": 1 - }, - "loan_renewal_mode": { - "$ref": "#/components/schemas/LoanRenewalModeEnum" - }, - "loan_max_renewals": { - "type": "integer", - "maximum": 100, - "minimum": 0 - }, - "loan_renewal_days": { - "type": "integer", - "maximum": 365, - "minimum": 1 - }, - "loan_max_active": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "loan_max_items": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "loan_reminder_offsets_hours": {}, - "loan_escalate_overdue_days": { - "type": "integer", - "maximum": 365, - "minimum": 0 + "new_password": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ - "default_loan_days", - "loan_escalate_overdue_days", - "loan_max_active", - "loan_max_items", - "loan_max_renewals", - "loan_reminder_offsets_hours", - "loan_renewal_days", - "loan_renewal_mode" + "new_password" ] }, - "PackMakerspaceMembershipSettings": { + "PatchedAccountProfile": { + "type": "object", + "properties": { + "display_name": { + "type": "string", + "maxLength": 200 + }, + "first_name": { + "type": "string", + "maxLength": 150 + }, + "last_name": { + "type": "string", + "maxLength": 150 + }, + "phone": { + "type": "string", + "maxLength": 32 + } + } + }, + "PatchedAdmissionSettings": { "type": "object", "properties": { + "configured": { + "type": "boolean", + "readOnly": true + }, "join_mode": { "$ref": "#/components/schemas/JoinModeEnum" }, "vouches_required": { "type": "integer", - "maximum": 32767, - "minimum": 0 + "maximum": 5, + "minimum": 1 }, "voucher_min_tenure_days": { "type": "integer", - "maximum": 2147483647, + "maximum": 3650, "minimum": 0 }, "voucher_pending_limit": { "type": "integer", - "maximum": 32767, - "minimum": 0 + "maximum": 100, + "minimum": 1 }, "vouch_expiry_days": { "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "maximum": 365, + "minimum": 1 }, "vouch_replaces_approval": { "type": "boolean" @@ -88926,7 +111317,7 @@ "nullable": true }, "default_plan": { - "type": "string", + "type": "integer", "nullable": true }, "joining_fee": { @@ -88936,393 +111327,221 @@ }, "joining_fee_currency": { "type": "string", - "maxLength": 3 + "pattern": "^[A-Za-z]{3}$" }, "rejoin_dues_rule": { "$ref": "#/components/schemas/RejoinDuesRuleEnum" }, "request_expiry_days": { "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "maximum": 365, + "minimum": 1 + }, + "recipe": { + "allOf": [ + { + "$ref": "#/components/schemas/RecipeEnum" + } + ], + "writeOnly": true } - }, - "required": [ - "application_schema", - "default_plan", - "join_mode", - "joining_fee", - "joining_fee_currency", - "payment_timing", - "rejoin_dues_rule", - "request_expiry_days", - "vouch_expiry_days", - "vouch_replaces_approval", - "voucher_min_tenure_days", - "voucher_pending_limit", - "vouches_required" - ] + } }, - "PackMakerspaceRole": { + "PatchedApiClient": { "type": "object", "properties": { - "name": { - "type": "string", - "maxLength": 80 + "id": { + "type": "integer", + "readOnly": true }, - "granted_actions": {} - }, - "required": [ - "granted_actions", - "name" - ] - }, - "PackMakerspaceWaiver": { - "type": "object", - "properties": { - "version": { + "label": { "type": "string", - "maxLength": 64 + "maxLength": 200 }, - "body": { - "type": "string" - } - }, - "required": [ - "body", - "version" - ] - }, - "PackMembershipPlan": { - "type": "object", - "properties": { - "name": { + "client_id": { "type": "string", - "maxLength": 120 + "readOnly": true }, - "interval": { - "$ref": "#/components/schemas/IntervalEnum" + "client_type": { + "$ref": "#/components/schemas/ClientTypeEnum" }, - "custom_days": { + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "rate_limit_tier": { + "$ref": "#/components/schemas/RateLimitTierEnum" + }, + "makerspace": { "type": "integer", - "maximum": 2147483647, - "minimum": 0, + "readOnly": true, "nullable": true }, - "amount": { + "public_makerspace_code": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "readOnly": true }, - "currency": { + "allowed_origins": { + "type": "array", + "items": { + "type": "string" + } + }, + "backend_base_url": { "type": "string", - "maxLength": 3 + "readOnly": true + }, + "public_api_base_url": { + "type": "string", + "readOnly": true }, "is_active": { "type": "boolean" }, - "loan_max_active": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0, + "last_seen_at": { + "type": "string", + "format": "date-time", + "readOnly": true, "nullable": true }, - "loan_max_items": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0, + "last_seen_ip": { + "type": "string", + "readOnly": true, "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true } - }, - "required": [ - "amount", - "currency", - "custom_days", - "interval", - "is_active", - "loan_max_active", - "loan_max_items", - "name" - ] + } }, - "PackNotificationPreference": { + "PatchedApiIntegrationSettings": { "type": "object", "properties": { - "feature": { - "$ref": "#/components/schemas/Feature4feEnum" + "public_code": { + "type": "string", + "readOnly": true }, - "channel": { - "$ref": "#/components/schemas/ChannelB30Enum" + "public_api_key": { + "type": "string", + "readOnly": true }, - "enabled": { + "cors_allowed_origins": { + "type": "array", + "items": { + "type": "string" + }, + "readOnly": true + }, + "telegram_group_chat_id": { + "type": "string", + "maxLength": 64 + }, + "telegram_bot_token": { + "type": "string", + "writeOnly": true + }, + "telegram_bot_token_set": { + "type": "boolean", + "readOnly": true + }, + "smtp_host": { + "type": "string", + "maxLength": 200 + }, + "smtp_port": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "smtp_username": { + "type": "string", + "maxLength": 200 + }, + "smtp_password": { + "type": "string", + "writeOnly": true + }, + "smtp_password_set": { + "type": "boolean", + "readOnly": true + }, + "smtp_use_tls": { "type": "boolean" - } - }, - "required": [ - "channel", - "enabled", - "feature" - ] - }, - "PackOpeningHours": { - "type": "object", - "properties": { - "timezone": { - "type": "string" }, - "opening_hours": { + "smtp_use_ssl": { + "type": "boolean" + }, + "smtp_from_email": { "oneOf": [ { - "type": "object", - "maxProperties": 0 + "type": "string", + "format": "email", + "maxLength": 254 }, { - "type": "object", - "required": [ - "weekly", - "exceptions" - ], - "additionalProperties": false, - "properties": { - "weekly": { - "oneOf": [ - { - "type": "object", - "maxProperties": 0 - }, - { - "type": "object", - "additionalProperties": false, - "required": [ - "mon", - "tue", - "wed", - "thu", - "fri", - "sat", - "sun" - ], - "properties": { - "mon": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "tue": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "wed": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "thu": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "fri": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "sat": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "sun": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - } - } - } - ] - }, - "exceptions": { - "type": "object", - "maxProperties": 366, - "additionalProperties": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "description": "YYYY-MM-DD keys; each value replaces that date's weekly hours." - } - } + "type": "string", + "maxLength": 0 } - ], - "description": "Empty object means always open. Empty weekly hours mean always open except dated overrides. Empty interval lists mean closed. Sorted intervals use local wall time; split overnight hours across days." + ] + }, + "slack_webhook_url": { + "type": "string", + "writeOnly": true, + "maxLength": 2048 + }, + "slack_webhook_url_set": { + "type": "boolean", + "readOnly": true + }, + "mattermost_webhook_url": { + "type": "string", + "writeOnly": true, + "maxLength": 2048 + }, + "mattermost_webhook_url_set": { + "type": "boolean", + "readOnly": true + }, + "discord_webhook_url": { + "type": "string", + "writeOnly": true, + "maxLength": 2048 + }, + "discord_webhook_url_set": { + "type": "boolean", + "readOnly": true + }, + "default_loan_days": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 } - }, - "required": [ - "opening_hours", - "timezone" - ] + } }, - "PackPreview": { + "PatchedAssignOperator": { "type": "object", "properties": { - "base_checksum": { - "type": "string" - }, - "applied": { - "type": "boolean" - }, - "changes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PackChange" - } + "user_id": { + "type": "integer" + }, + "access_level": { + "type": "string", + "maxLength": 16 } - }, - "required": [ - "applied", - "base_checksum", - "changes" - ] + } }, - "PackProfile": { + "PatchedBackupDestination": { "type": "object", + "description": "Credentials are write-only; reads report only whether they are stored.", "properties": { "id": { "type": "integer", @@ -89330,1501 +111549,1372 @@ }, "name": { "type": "string", - "maxLength": 120 - }, - "pack": { - "$ref": "#/components/schemas/SpacePack" - }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "maxLength": 64, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "updated_at": { - "type": "string", - "format": "date-time", + "kind": { + "allOf": [ + { + "$ref": "#/components/schemas/BackupDestinationKindEnum" + } + ], "readOnly": true - } - }, - "required": [ - "created_at", - "id", - "name", - "pack", - "updated_at" - ] - }, - "PackSettings": { - "type": "object", - "description": "Schema only: actual writes validate through MakerspaceSerializer.", - "properties": { - "public_inventory_enabled": { - "type": "boolean" - }, - "public_site_enabled": { - "type": "boolean" }, - "public_landing_page": { - "$ref": "#/components/schemas/PublicLandingPage6a0Enum" + "endpoint_url": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "maxLength": 300 + }, + { + "type": "string", + "maxLength": 0 + } + ] }, - "public_stats_enabled": { - "type": "boolean" + "bucket": { + "type": "string", + "maxLength": 255 }, - "public_stats_show_holder_names": { - "type": "boolean" + "region": { + "type": "string", + "maxLength": 64 }, - "public_print_status_lookup_policy": { - "$ref": "#/components/schemas/PublicPrintStatusLookupPolicyEnum" + "prefix": { + "type": "string", + "maxLength": 200 }, - "membership_policy": { - "$ref": "#/components/schemas/MembershipPolicyEnum" + "addressing_style": { + "$ref": "#/components/schemas/AddressingStyleEnum" }, - "membership_dues_amount": { + "access_key_id": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - }, - "referrals_enabled": { - "type": "boolean" + "writeOnly": true, + "maxLength": 200 }, - "lapsed_members_cannot_borrow": { - "type": "boolean" + "secret_access_key": { + "type": "string", + "writeOnly": true, + "maxLength": 200 }, - "staff_notifications_enabled": { - "type": "boolean" + "credentials_set": { + "type": "boolean", + "readOnly": true }, - "booking_requester_notifications_enabled": { + "enabled": { "type": "boolean" }, - "default_loan_days": { + "retention_days": { "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "maximum": 3650, + "minimum": 1 }, - "presence_preset_minutes": {} - }, - "required": [ - "booking_requester_notifications_enabled", - "default_loan_days", - "lapsed_members_cannot_borrow", - "membership_dues_amount", - "membership_policy", - "presence_preset_minutes", - "public_inventory_enabled", - "public_landing_page", - "public_print_status_lookup_policy", - "public_site_enabled", - "public_stats_enabled", - "public_stats_show_holder_names", - "referrals_enabled", - "staff_notifications_enabled" - ] - }, - "PaginatedAdminRequestList": { - "type": "object", - "required": [ - "count", - "results" - ], - "properties": { - "count": { - "type": "integer", - "example": 123 + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true }, - "next": { + "updated_at": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "format": "date-time", + "readOnly": true }, - "previous": { + "retired_at": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "format": "date-time", + "readOnly": true, + "nullable": true }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AdminRequest" - } + "last_success_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "last_error_code": { + "type": "string", + "readOnly": true } } }, - "PaginatedApiClientList": { + "PatchedBillingSettings": { "type": "object", - "required": [ - "count", - "results" - ], "properties": { - "count": { + "payment_terms_days": { "type": "integer", - "example": 123 - }, - "next": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "maximum": 365, + "minimum": 0 }, - "previous": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "reminder_days_before": { + "type": "array", + "items": { + "type": "integer", + "maximum": 365, + "minimum": 1 + }, + "maxItems": 20 }, - "results": { + "reminder_days_after": { "type": "array", "items": { - "$ref": "#/components/schemas/ApiClient" - } - } - } - }, - "PaginatedApiKeyRequestList": { - "type": "object", - "required": [ - "count", - "results" - ], - "properties": { - "count": { + "type": "integer", + "maximum": 365, + "minimum": 1 + }, + "maxItems": 20 + }, + "max_reminders": { "type": "integer", - "example": 123 + "maximum": 20, + "minimum": 0 }, - "next": { + "number_prefix": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "maxLength": 32 }, - "previous": { + "legal_name": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "maxLength": 150 }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ApiKeyRequest" - } - } - } - }, - "PaginatedAuditLogList": { - "type": "object", - "required": [ - "count", - "results" - ], - "properties": { - "count": { - "type": "integer", - "example": 123 + "address": { + "type": "string", + "maxLength": 1000 }, - "next": { + "tax_id": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "maxLength": 64 }, - "previous": { + "footer": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "maxLength": 1000 }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AuditLog" - } - } + "account_codes": {} } }, - "PaginatedBindingList": { + "PatchedBindingUpdate": { "type": "object", - "required": [ - "count", - "results" - ], "properties": { - "count": { - "type": "integer", - "example": 123 - }, - "next": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" - }, - "previous": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "enabled": { + "type": "boolean" }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Binding" - } + "auto_apply_terminal": { + "type": "boolean" } } }, - "PaginatedBoxList": { + "PatchedBookableSpaceBookingRules": { "type": "object", - "required": [ - "count", - "results" - ], "properties": { - "count": { + "min_booking_duration_minutes": { "type": "integer", - "example": 123 + "maximum": 2147483647, + "minimum": 1 }, - "next": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "max_booking_duration_minutes": { + "type": "integer", + "maximum": 2147483647, + "minimum": 1 }, - "previous": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "booking_lead_time_minutes": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Box" - } - } - } - }, - "PaginatedCategoryAdminList": { - "type": "object", - "required": [ - "count", - "results" - ], - "properties": { - "count": { + "max_booking_advance_days": { "type": "integer", - "example": 123 + "maximum": 2147483647, + "minimum": 1 }, - "next": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "buffer_before_minutes": { + "type": "integer", + "maximum": 1440, + "minimum": 0 }, - "previous": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "buffer_after_minutes": { + "type": "integer", + "maximum": 1440, + "minimum": 0 }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CategoryAdmin" - } + "cancellation_cutoff_hours": { + "type": "integer", + "maximum": 8760, + "minimum": 0 + }, + "approval_mode": { + "$ref": "#/components/schemas/ApprovalModeE35Enum" } } }, - "PaginatedDeviceList": { + "PatchedBookableSpaceWrite": { "type": "object", - "required": [ - "count", - "results" - ], "properties": { - "count": { - "type": "integer", - "example": 123 - }, - "next": { + "name": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "maxLength": 200 }, - "previous": { + "kind": { + "allOf": [ + { + "$ref": "#/components/schemas/Kind3bfEnum" + } + ], + "default": "other" + }, + "description": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "default": "" }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Device" - } - } - } - }, - "PaginatedDirectLoanList": { - "type": "object", - "required": [ - "count", - "results" - ], - "properties": { - "count": { + "capacity": { "type": "integer", - "example": 123 + "minimum": 0, + "default": 0 }, - "next": { + "location": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "default": "", + "maxLength": 255 }, - "previous": { + "is_public": { + "type": "boolean", + "default": false + }, + "show_public_availability": { + "type": "boolean", + "default": false + }, + "show_public_booker_names": { + "type": "boolean", + "default": false + }, + "custom_form": { + "nullable": true + }, + "requester_notifications_enabled": { + "type": "boolean", + "nullable": true + }, + "payment_amount": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "default": "0.00" }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DirectLoan" - } + "machine_type": { + "type": "integer", + "minimum": 1, + "nullable": true } } }, - "PaginatedDirectLoanMemberList": { + "PatchedBookingDetailsInput": { "type": "object", - "required": [ - "count", - "results" - ], "properties": { - "count": { - "type": "integer", - "example": 123 - }, - "next": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" - }, - "previous": { + "note": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "maxLength": 5000 }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DirectLoanMember" - } + "custom_answers": { + "nullable": true } } }, - "PaginatedEmailLogList": { + "PatchedBookingStaffGrantPermissions": { "type": "object", - "required": [ - "count", - "results" - ], "properties": { - "count": { - "type": "integer", - "example": 123 - }, - "next": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" - }, - "previous": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" - }, - "results": { + "permissions": { "type": "array", "items": { - "$ref": "#/components/schemas/EmailLog" - } + "$ref": "#/components/schemas/Permissions43eEnum" + }, + "maxItems": 4, + "minItems": 1 } } }, - "PaginatedInventoryAssetAdminList": { + "PatchedBox": { "type": "object", - "required": [ - "count", - "results" - ], "properties": { - "count": { + "id": { "type": "integer", - "example": 123 + "readOnly": true }, - "next": { + "makerspace": { + "type": "integer", + "readOnly": true + }, + "parent": { + "type": "integer", + "nullable": true + }, + "code": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "readOnly": true }, - "previous": { + "label": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "maxLength": 200 }, - "results": { + "location": { + "type": "string", + "maxLength": 200 + }, + "description": { + "type": "string" + }, + "kit_components": { "type": "array", "items": { - "$ref": "#/components/schemas/InventoryAssetAdmin" - } - } - } - }, - "PaginatedInventoryProductAdminList": { - "type": "object", - "required": [ - "count", - "results" - ], - "properties": { - "count": { - "type": "integer", - "example": 123 + "$ref": "#/components/schemas/KitComponent" + }, + "maxItems": 50 }, - "next": { - "type": "string", + "is_active": { + "type": "boolean" + }, + "qr_code_id": { + "type": "integer", "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "readOnly": true }, - "previous": { + "created_at": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "format": "date-time", + "readOnly": true }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/InventoryProductAdmin" - } + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true } } }, - "PaginatedJobList": { + "PatchedCategoryAdmin": { "type": "object", - "required": [ - "count", - "results" - ], "properties": { - "count": { + "id": { "type": "integer", - "example": 123 + "readOnly": true }, - "next": { + "makerspace": { + "type": "integer", + "readOnly": true + }, + "name": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "maxLength": 100 }, - "previous": { + "slug": { + "oneOf": [ + { + "type": "string", + "pattern": "^[-a-zA-Z0-9_]+$" + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "display_order": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "loan_days": { + "type": "integer", + "maximum": 365, + "minimum": 1, + "nullable": true + }, + "max_renewals": { + "type": "integer", + "maximum": 100, + "minimum": 0, + "nullable": true + }, + "icon": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "maxLength": 50 }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Job" - } + "product_count": { + "type": "integer", + "readOnly": true, + "default": 0 + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true } } }, - "PaginatedLabelCommandList": { + "PatchedCertificationTypeUpdate": { "type": "object", - "required": [ - "count", - "results" - ], "properties": { - "count": { + "id": { "type": "integer", - "example": 123 + "readOnly": true }, - "next": { + "machine_type": { + "type": "integer", + "readOnly": true + }, + "name": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "maxLength": 200 }, - "previous": { + "description": { + "type": "string" + }, + "validity_days": { + "type": "integer", + "maximum": 2147483647, + "minimum": 1, + "nullable": true + }, + "is_required_for_service": { + "type": "boolean" + }, + "is_required_for_booking": { + "type": "boolean" + }, + "is_active": { + "type": "boolean", + "readOnly": true + }, + "created_at": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "format": "date-time", + "readOnly": true }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LabelCommand" - } + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true } } }, - "PaginatedMaterialList": { + "PatchedDeviceUpdate": { "type": "object", - "required": [ - "count", - "results" - ], "properties": { - "count": { - "type": "integer", - "example": 123 - }, - "next": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" - }, - "previous": { + "name": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "maxLength": 120 }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Material" - } + "offline_threshold_seconds": { + "type": "integer", + "maximum": 604800, + "minimum": 1 } } }, - "PaginatedNotificationList": { + "PatchedEmailTemplateUpdate": { "type": "object", - "required": [ - "count", - "results" - ], "properties": { - "count": { - "type": "integer", - "example": 123 - }, - "next": { + "subject": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "maxLength": 200 }, - "previous": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "text_body": { + "type": "string" }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Notification" - } + "html_body": { + "type": "string" + }, + "is_active": { + "type": "boolean" } } }, - "PaginatedOverdueScheduleList": { + "PatchedEventSeriesWrite": { "type": "object", - "required": [ - "count", - "results" - ], "properties": { - "count": { + "registration_audience": { + "$ref": "#/components/schemas/EventSeriesWriteRegistrationAudienceEnum" + }, + "ticket_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TicketInput" + }, + "writeOnly": true + }, + "title": { + "type": "string", + "maxLength": 200 + }, + "description": { + "type": "string", + "default": "" + }, + "location": { + "type": "string", + "default": "", + "maxLength": 255 + }, + "location_kind": { + "allOf": [ + { + "$ref": "#/components/schemas/LocationKindEnum" + } + ], + "default": "other" + }, + "custom_form": { + "nullable": true + }, + "capacity": { "type": "integer", - "example": 123 + "minimum": 0, + "default": 0 }, - "next": { + "payment_amount": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "default": "0.00" }, - "previous": { + "registration_requires_approval": { + "type": "boolean", + "default": false + }, + "cancellation_cutoff_hours": { + "type": "integer", + "maximum": 8760, + "minimum": 0, + "nullable": true + }, + "registration_cutoff_lead_minutes": { + "type": "integer", + "minimum": 0, + "nullable": true + }, + "is_public": { + "type": "boolean", + "default": false + }, + "recurrence_timezone": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "maxLength": 64 }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/OverdueSchedule" - } + "dtstart_local_date": { + "type": "string", + "format": "date" + }, + "dtstart_local_time": { + "type": "string", + "format": "time" + }, + "recurrence_rule": { + "type": "string", + "maxLength": 500 + }, + "duration_minutes": { + "type": "integer", + "minimum": 1 + }, + "effective_from": { + "type": "string", + "format": "date-time", + "writeOnly": true } } }, - "PaginatedPackProfileList": { + "PatchedEventWrite": { "type": "object", - "required": [ - "count", - "results" - ], "properties": { - "count": { + "registration_audience": { + "$ref": "#/components/schemas/EventDefaultRegistrationAudienceEnum" + }, + "category_id": { "type": "integer", - "example": 123 + "minimum": 1, + "nullable": true }, - "next": { + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 50 + }, + "maxItems": 20 + }, + "title": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "maxLength": 200 }, - "previous": { + "description": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "default": "" }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PackProfile" - } - } - } - }, - "PaginatedPublicMachineDetailList": { - "type": "object", - "required": [ - "count", - "results" - ], - "properties": { - "count": { + "starts_at": { + "type": "string", + "format": "date-time" + }, + "ends_at": { + "type": "string", + "format": "date-time" + }, + "timezone_name": { + "type": "string", + "maxLength": 64 + }, + "location": { + "type": "string", + "default": "", + "maxLength": 255 + }, + "location_kind": { + "allOf": [ + { + "$ref": "#/components/schemas/LocationKindEnum" + } + ], + "default": "other" + }, + "custom_form": { + "nullable": true + }, + "capacity": { "type": "integer", - "example": 123 + "minimum": 0, + "default": 0 }, - "next": { + "payment_amount": { "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "default": "0.00" + }, + "is_public": { + "type": "boolean", + "default": false + }, + "show_registration_count": { + "type": "boolean", + "default": true + }, + "registration_requires_approval": { + "type": "boolean", + "default": false + }, + "cancellation_cutoff_hours": { + "type": "integer", + "maximum": 8760, + "minimum": 0, "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "description": "Member cancellation closes this many hours before start; null means start." }, - "previous": { + "registration_cutoff_at": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "format": "date-time", + "nullable": true }, - "results": { + "registration_cutoff_lead_minutes": { + "type": "integer", + "minimum": 0, + "nullable": true + }, + "inherit_fields": { "type": "array", "items": { - "$ref": "#/components/schemas/PublicMachineDetail" - } + "$ref": "#/components/schemas/InheritFieldsEnum" + }, + "writeOnly": true } } }, - "PaginatedPublicMachineList": { + "PatchedEvidenceRetentionPatch": { "type": "object", - "required": [ - "count", - "results" - ], + "description": "True partial update: supply any subset; an empty body is rejected.", "properties": { - "count": { + "object_retention_days": { "type": "integer", - "example": 123 - }, - "next": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "maximum": 3650, + "minimum": 30, + "nullable": true }, - "previous": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "abandoned_upload_cleanup_enabled": { + "type": "boolean" }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicMachine" - } + "abandoned_upload_retention_days": { + "type": "integer", + "maximum": 3650, + "minimum": 1, + "nullable": true } } }, - "PaginatedPublicProductList": { + "PatchedInventoryAssetAdminUpdate": { "type": "object", - "required": [ - "count", - "results" - ], "properties": { - "count": { - "type": "integer", - "example": 123 - }, - "next": { + "asset_tag": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "maxLength": 100 }, - "previous": { + "serial_number": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "maxLength": 100 }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicProduct" - } + "box": { + "type": "integer", + "nullable": true + }, + "notes": { + "type": "string" + }, + "public_self_checkout_enabled": { + "type": "boolean" } } }, - "PaginatedQrPrintBatchList": { + "PatchedInventoryProductAdminUpdate": { "type": "object", - "required": [ - "count", - "results" - ], "properties": { - "count": { + "id": { "type": "integer", - "example": 123 + "readOnly": true }, - "next": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "makerspace": { + "type": "integer", + "readOnly": true }, - "previous": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "box": { + "type": "integer", + "nullable": true }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QrPrintBatch" - } - } - } - }, - "PaginatedReorderSuggestionList": { - "type": "object", - "required": [ - "count", - "results" - ], - "properties": { - "count": { + "category": { "type": "integer", - "example": 123 + "nullable": true }, - "next": { + "name": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "maxLength": 200 }, - "previous": { + "manufacturer": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "maxLength": 200 }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ReorderSuggestion" - } - } - } - }, - "PaginatedReplayRecoveryList": { - "type": "object", - "required": [ - "count", - "results" - ], - "properties": { - "count": { - "type": "integer", - "example": 123 + "model": { + "type": "string", + "maxLength": 200 }, - "next": { + "sku": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "maxLength": 100 }, - "previous": { + "barcode": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "maxLength": 100 }, - "results": { + "supplier": { + "type": "string", + "maxLength": 200 + }, + "reorder_point": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "reorder_quantity": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "kit_components": { "type": "array", "items": { - "$ref": "#/components/schemas/ReplayRecovery" - } - } - } - }, - "PaginatedReportScheduleList": { - "type": "object", - "required": [ - "count", - "results" - ], - "properties": { - "count": { - "type": "integer", - "example": 123 + "$ref": "#/components/schemas/KitComponent" + }, + "maxItems": 50 }, - "next": { + "description": { + "type": "string" + }, + "unit_cost": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,4})?$", + "nullable": true }, - "previous": { + "cost_currency": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "maxLength": 3 }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ReportSchedule" - } - } - } - }, - "PaginatedScheduleList": { - "type": "object", - "required": [ - "count", - "results" - ], - "properties": { - "count": { - "type": "integer", - "example": 123 + "cost_unit": { + "type": "string", + "maxLength": 16 }, - "next": { + "borrowing_terms": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "maxLength": 2000 }, - "previous": { + "image_key": { + "type": "string", + "readOnly": true + }, + "image_url": { "type": "string", - "nullable": true, "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "nullable": true, + "readOnly": true }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Schedule" - } - } - } - }, - "PaginatedStaffInvitationList": { - "type": "object", - "required": [ - "count", - "results" - ], - "properties": { - "count": { + "tracking_mode": { + "$ref": "#/components/schemas/TrackingModeB86Enum" + }, + "loan_days": { "type": "integer", - "example": 123 + "maximum": 365, + "minimum": 1, + "nullable": true }, - "next": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "max_renewals": { + "type": "integer", + "maximum": 100, + "minimum": 0, + "nullable": true }, - "previous": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "total_quantity": { + "type": "integer", + "readOnly": true }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StaffInvitation" - } - } - } - }, - "PaginatedStaffMembershipList": { - "type": "object", - "required": [ - "count", - "results" - ], - "properties": { - "count": { + "available_quantity": { "type": "integer", - "example": 123 + "readOnly": true }, - "next": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "reserved_quantity": { + "type": "integer", + "readOnly": true }, - "previous": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "issued_quantity": { + "type": "integer", + "readOnly": true }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StaffMembership" - } - } - } - }, - "PaginatedStockTransferList": { - "type": "object", - "required": [ - "count", - "results" - ], - "properties": { - "count": { + "damaged_quantity": { "type": "integer", - "example": 123 + "readOnly": true }, - "next": { + "lost_quantity": { + "type": "integer", + "readOnly": true + }, + "needs_fix_quantity": { + "type": "integer", + "readOnly": true + }, + "is_public": { + "type": "boolean" + }, + "public_self_checkout_enabled": { + "type": "boolean" + }, + "show_public_count": { + "type": "boolean" + }, + "public_availability_mode": { + "$ref": "#/components/schemas/PublicAvailabilityModeFc2Enum" + }, + "storage_location": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "maxLength": 200 }, - "previous": { + "is_archived": { + "type": "boolean" + }, + "created_at": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "format": "date-time", + "readOnly": true }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StockTransfer" - } + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true } } }, - "PaginatedStocktakeList": { + "PatchedMachine": { "type": "object", - "required": [ - "count", - "results" - ], "properties": { - "count": { + "id": { "type": "integer", - "example": 123 + "readOnly": true }, - "next": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "makerspace": { + "type": "integer", + "readOnly": true }, - "previous": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "machine_type": { + "allOf": [ + { + "$ref": "#/components/schemas/MachineType" + } + ], + "readOnly": true }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Stocktake" - } - } - } - }, - "PaginatedSubdomainRequestList": { - "type": "object", - "required": [ - "count", - "results" - ], - "properties": { - "count": { + "machine_type_id": { "type": "integer", - "example": 123 + "writeOnly": true }, - "next": { + "name": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "maxLength": 200 }, - "previous": { + "location": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "maxLength": 200 }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SubdomainRequest" - } - } - } - }, - "PaginatedWarrantyReportRowList": { - "type": "object", - "required": [ - "count", - "results" - ], - "properties": { - "count": { - "type": "integer", - "example": 123 + "notes": { + "type": "string" + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/MachineStatusEnum" + } + ], + "readOnly": true + }, + "firmware_version": { + "type": "string", + "maxLength": 100 + }, + "camera_feed_url": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "maxLength": 200 + }, + { + "type": "string", + "maxLength": 0 + } + ] }, - "next": { + "status_since": { "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" + "format": "date-time", + "readOnly": true }, - "previous": { + "public_fields": {}, + "type_payload": {}, + "image_url": { "type": "string", "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" + "readOnly": true }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WarrantyReportRow" - } - } - } - }, - "Pairing": { - "type": "object", - "properties": { - "id": { + "warranty_status": { "type": "string", - "format": "uuid", "readOnly": true }, - "migration_id": { - "type": "string", - "format": "uuid" + "is_public": { + "type": "boolean", + "readOnly": true }, - "source_tenant_id": { - "type": "string", - "maxLength": 64 + "is_active": { + "type": "boolean", + "readOnly": true }, - "archive_digest": { - "type": "string", - "maxLength": 64 + "usage_hours": { + "type": "number", + "format": "double", + "readOnly": true }, - "source_deployment_id": { - "type": "string", - "maxLength": 128 + "can_operate": { + "type": "boolean", + "readOnly": true }, - "source_fingerprint": { - "type": "string", - "maxLength": 64 + "can_edit": { + "type": "boolean", + "readOnly": true }, - "target_deployment_id": { - "type": "string", - "maxLength": 128 + "can_delegate": { + "type": "boolean", + "readOnly": true }, - "target_fingerprint": { - "type": "string", - "maxLength": 64 + "can_retire": { + "type": "boolean", + "readOnly": true }, - "approved_at": { - "type": "string", - "format": "date-time", + "can_unretire": { + "type": "boolean", "readOnly": true - } - }, - "required": [ - "approved_at", - "archive_digest", - "id", - "migration_id", - "source_deployment_id", - "source_fingerprint", - "source_tenant_id", - "target_deployment_id", - "target_fingerprint" - ] - }, - "PairingCreate": { - "type": "object", - "properties": { - "migration_id": { - "type": "string", - "format": "uuid" }, - "source_tenant_id": { - "type": "string", - "maxLength": 64 + "can_manage": { + "type": "boolean", + "readOnly": true }, - "archive_digest": { + "created_at": { "type": "string", - "pattern": "^[0-9a-f]{64}$" - }, - "source": { - "type": "object", - "additionalProperties": {} - }, - "target": { - "type": "object", - "additionalProperties": {} - } - }, - "required": [ - "archive_digest", - "migration_id", - "source", - "source_tenant_id", - "target" - ] - }, - "PaperSizeEnum": { - "enum": [ - "A4", - "LETTER", - "custom" - ], - "type": "string", - "description": "* `A4` - A4\n* `LETTER` - LETTER\n* `custom` - custom" - }, - "PasswordLoginResult": { - "oneOf": [ - { - "$ref": "#/components/schemas/LoginResponse" + "format": "date-time", + "readOnly": true }, - { - "$ref": "#/components/schemas/MFAChallengeResponse" - } - ] - }, - "PasswordResetAcknowledgement": { - "type": "object", - "properties": { - "detail": { - "type": "string" - } - }, - "required": [ - "detail" - ] - }, - "PasswordResetFailure": { - "type": "object", - "properties": { - "detail": { - "type": "string" - } - }, - "required": [ - "detail" - ] - }, - "PasswordUpdated": { - "type": "object", - "properties": { - "detail": { - "type": "string" + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true } - }, - "required": [ - "detail" - ] + } }, - "PasswordValidationFailure": { + "PatchedMachinePublicity": { "type": "object", "properties": { - "new_password": { - "type": "array", - "items": { - "type": "string" - } + "is_public": { + "type": "boolean" } - }, - "required": [ - "new_password" - ] + } }, - "PatchedAccountProfile": { + "PatchedMachineTypeUpdate": { "type": "object", "properties": { - "display_name": { + "name": { "type": "string", "maxLength": 200 }, - "first_name": { - "type": "string", - "maxLength": 150 - }, - "last_name": { + "icon": { "type": "string", - "maxLength": 150 + "maxLength": 50 }, - "phone": { - "type": "string", - "maxLength": 32 - } + "capability_config": {} } }, - "PatchedAdmissionSettings": { + "PatchedMaintenanceScheduleWrite": { "type": "object", "properties": { - "configured": { - "type": "boolean", - "readOnly": true - }, - "join_mode": { - "$ref": "#/components/schemas/JoinModeEnum" + "description": { + "type": "string" }, - "vouches_required": { + "interval_days": { "type": "integer", - "maximum": 5, "minimum": 1 }, - "voucher_min_tenure_days": { - "type": "integer", - "maximum": 3650, - "minimum": 0 + "next_due": { + "type": "string", + "format": "date" }, - "voucher_pending_limit": { + "trigger": { + "$ref": "#/components/schemas/MaintenanceScheduleWriteTriggerEnum" + }, + "usage_hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "nullable": true + }, + "job_count": { "type": "integer", - "maximum": 100, - "minimum": 1 + "minimum": 1, + "nullable": true }, - "vouch_expiry_days": { + "anchor": { + "$ref": "#/components/schemas/AnchorEnum" + } + } + }, + "PatchedMakerspace": { + "type": "object", + "properties": { + "organization_id": { "type": "integer", - "maximum": 365, - "minimum": 1 + "minimum": 1, + "writeOnly": true, + "description": "Existing active owner organization ID. On create, supply this or organization_name, exactly one; refused on update." }, - "vouch_replaces_approval": { - "type": "boolean" + "organization_name": { + "type": "string", + "writeOnly": true, + "description": "Create a new owner organization with this name. On create, supply this or organization_id, exactly one; refused on update.", + "maxLength": 200 }, - "payment_timing": { - "$ref": "#/components/schemas/PaymentTimingEnum" + "creation_setup": { + "allOf": [ + { + "$ref": "#/components/schemas/MakerspaceCreationSetup" + } + ], + "writeOnly": true }, - "application_schema": { + "creation_invitation": { + "allOf": [ + { + "$ref": "#/components/schemas/CreationInviteDelivery" + } + ], + "readOnly": true, "nullable": true }, - "default_plan": { + "install_profile_id": { "type": "integer", - "nullable": true + "minimum": 1, + "writeOnly": true }, - "joining_fee": { + "id": { + "type": "integer", + "readOnly": true + }, + "name": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "maxLength": 200 }, - "joining_fee_currency": { + "public_code": { "type": "string", - "pattern": "^[A-Za-z]{3}$" + "pattern": "^[A-Z0-9]{4}$", + "maxLength": 4 }, - "rejoin_dues_rule": { - "$ref": "#/components/schemas/RejoinDuesRuleEnum" + "slug": { + "type": "string", + "maxLength": 50, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "request_expiry_days": { - "type": "integer", - "maximum": 365, - "minimum": 1 + "location": { + "type": "string", + "maxLength": 200 }, - "recipe": { - "allOf": [ + "timezone": { + "type": "string", + "readOnly": true + }, + "country": { + "type": "string", + "readOnly": true + }, + "opening_hours": { + "oneOf": [ { - "$ref": "#/components/schemas/RecipeEnum" + "type": "object", + "maxProperties": 0 + }, + { + "type": "object", + "required": [ + "weekly", + "exceptions" + ], + "additionalProperties": false, + "properties": { + "weekly": { + "oneOf": [ + { + "type": "object", + "maxProperties": 0 + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ], + "properties": { + "mon": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "tue": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "wed": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "thu": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "fri": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "sat": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "sun": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + } + } + } + ] + }, + "exceptions": { + "type": "object", + "maxProperties": 366, + "additionalProperties": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "description": "YYYY-MM-DD keys; each value replaces that date's weekly hours." + } + } } ], - "writeOnly": true - } - } - }, - "PatchedApiClient": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "label": { - "type": "string", - "maxLength": 200 - }, - "client_id": { - "type": "string", - "readOnly": true - }, - "client_type": { - "$ref": "#/components/schemas/ClientTypeEnum" - }, - "scopes": { - "type": "array", - "items": { - "type": "string" - } - }, - "rate_limit_tier": { - "$ref": "#/components/schemas/RateLimitTierEnum" - }, - "makerspace": { - "type": "integer", - "readOnly": true, - "nullable": true - }, - "public_makerspace_code": { - "type": "string", - "readOnly": true - }, - "allowed_origins": { - "type": "array", - "items": { - "type": "string" - } - }, - "backend_base_url": { - "type": "string", - "readOnly": true - }, - "public_api_base_url": { - "type": "string", - "readOnly": true - }, - "is_active": { - "type": "boolean" - }, - "last_seen_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "last_seen_ip": { - "type": "string", - "readOnly": true, - "nullable": true - }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "updated_at": { - "type": "string", - "format": "date-time", - "readOnly": true - } - } - }, - "PatchedApiIntegrationSettings": { - "type": "object", - "properties": { - "public_code": { - "type": "string", - "readOnly": true - }, - "public_api_key": { - "type": "string", - "readOnly": true - }, - "cors_allowed_origins": { - "type": "array", - "items": { - "type": "string" - }, - "readOnly": true - }, - "telegram_group_chat_id": { - "type": "string", - "maxLength": 64 - }, - "telegram_bot_token": { - "type": "string", - "writeOnly": true - }, - "telegram_bot_token_set": { - "type": "boolean", - "readOnly": true - }, - "smtp_host": { - "type": "string", - "maxLength": 200 - }, - "smtp_port": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "smtp_username": { - "type": "string", - "maxLength": 200 - }, - "smtp_password": { - "type": "string", - "writeOnly": true - }, - "smtp_password_set": { - "type": "boolean", + "description": "Empty object means always open. Empty weekly hours mean always open except dated overrides. Empty interval lists mean closed. Sorted intervals use local wall time; split overnight hours across days.", "readOnly": true }, - "smtp_use_tls": { - "type": "boolean" - }, - "smtp_use_ssl": { - "type": "boolean" - }, - "smtp_from_email": { + "map_url": { "oneOf": [ { "type": "string", - "format": "email", - "maxLength": 254 + "format": "uri", + "maxLength": 200 }, { "type": "string", @@ -90832,397 +112922,282 @@ } ] }, - "slack_webhook_url": { - "type": "string", - "writeOnly": true, - "maxLength": 2048 - }, - "slack_webhook_url_set": { - "type": "boolean", - "readOnly": true - }, - "mattermost_webhook_url": { + "geofence_latitude": { "type": "string", - "writeOnly": true, - "maxLength": 2048 - }, - "mattermost_webhook_url_set": { - "type": "boolean", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,3}(?:\\.\\d{0,6})?$", + "nullable": true }, - "discord_webhook_url": { + "geofence_longitude": { "type": "string", - "writeOnly": true, - "maxLength": 2048 - }, - "discord_webhook_url_set": { - "type": "boolean", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,3}(?:\\.\\d{0,6})?$", + "nullable": true }, - "default_loan_days": { + "geofence_radius_m": { "type": "integer", "maximum": 2147483647, - "minimum": 0 - } - } - }, - "PatchedAssignOperator": { - "type": "object", - "properties": { - "user_id": { - "type": "integer" - }, - "access_level": { - "type": "string", - "maxLength": 16 - } - } - }, - "PatchedBackupDestination": { - "type": "object", - "description": "Credentials are write-only; reads report only whether they are stored.", - "properties": { - "id": { - "type": "integer", - "readOnly": true + "minimum": 1 }, - "name": { - "type": "string", - "maxLength": 64, - "pattern": "^[-a-zA-Z0-9_]+$" + "geofence_enabled": { + "type": "boolean" }, - "kind": { - "allOf": [ - { - "$ref": "#/components/schemas/BackupDestinationKindEnum" - } - ], - "readOnly": true + "public_inventory_enabled": { + "type": "boolean" }, - "endpoint_url": { - "oneOf": [ - { - "type": "string", - "format": "uri", - "maxLength": 300 - }, - { - "type": "string", - "maxLength": 0 - } - ] + "public_site_enabled": { + "type": "boolean" }, - "bucket": { - "type": "string", - "maxLength": 255 + "public_landing_page": { + "$ref": "#/components/schemas/PublicLandingPage6a0Enum" }, - "region": { - "type": "string", - "maxLength": 64 + "public_stats_enabled": { + "type": "boolean" }, - "prefix": { - "type": "string", - "maxLength": 200 + "public_stats_show_holder_names": { + "type": "boolean" }, - "addressing_style": { - "$ref": "#/components/schemas/AddressingStyleEnum" + "public_print_status_lookup_policy": { + "$ref": "#/components/schemas/PublicPrintStatusLookupPolicyEnum" }, - "access_key_id": { - "type": "string", - "writeOnly": true, - "maxLength": 200 + "membership_policy": { + "$ref": "#/components/schemas/MembershipPolicyEnum" }, - "secret_access_key": { + "membership_dues_amount": { "type": "string", - "writeOnly": true, - "maxLength": 200 + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "credentials_set": { - "type": "boolean", - "readOnly": true + "referrals_enabled": { + "type": "boolean" }, - "enabled": { + "lapsed_members_cannot_borrow": { "type": "boolean" }, - "retention_days": { - "type": "integer", - "maximum": 3650, - "minimum": 1 + "anonymous_requests_enabled": { + "type": "boolean" }, - "created_at": { + "request_access": { "type": "string", - "format": "date-time", "readOnly": true }, - "updated_at": { + "filament_low_stock_threshold_grams": { "type": "string", - "format": "date-time", + "format": "decimal", + "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$" + }, + "superadmin_access_enabled": { + "type": "boolean" + }, + "archive_custody_state": { + "allOf": [ + { + "$ref": "#/components/schemas/ArchiveCustodyStateEnum" + } + ], + "nullable": true, "readOnly": true }, - "retired_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "staff_notifications_enabled": { + "type": "boolean" }, - "last_success_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "booking_requester_notifications_enabled": { + "type": "boolean" }, - "last_error_code": { - "type": "string", - "readOnly": true - } - } - }, - "PatchedBillingSettings": { - "type": "object", - "properties": { - "payment_terms_days": { - "type": "integer", - "maximum": 365, - "minimum": 0 + "event_default_registration_audience": { + "$ref": "#/components/schemas/EventDefaultRegistrationAudienceEnum" }, - "reminder_days_before": { + "event_reminder_lead_hours": { "type": "array", "items": { "type": "integer", - "maximum": 365, + "maximum": 8760, "minimum": 1 }, - "maxItems": 20 + "maxItems": 100 }, - "reminder_days_after": { + "booking_reminder_lead_hours": { "type": "array", "items": { "type": "integer", - "maximum": 365, + "maximum": 8760, "minimum": 1 }, - "maxItems": 20 - }, - "max_reminders": { - "type": "integer", - "maximum": 20, - "minimum": 0 - }, - "number_prefix": { - "type": "string", - "maxLength": 32 + "maxItems": 100 }, - "legal_name": { + "logo_key": { "type": "string", - "maxLength": 150 + "readOnly": true }, - "address": { + "logo_url": { "type": "string", - "maxLength": 1000 + "format": "uri", + "nullable": true, + "readOnly": true }, - "tax_id": { + "cover_image_key": { "type": "string", - "maxLength": 64 + "readOnly": true }, - "footer": { + "cover_image_url": { "type": "string", - "maxLength": 1000 - }, - "account_codes": {} - } - }, - "PatchedBindingUpdate": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "auto_apply_terminal": { - "type": "boolean" - } - } - }, - "PatchedBookableSpaceBookingRules": { - "type": "object", - "properties": { - "min_booking_duration_minutes": { - "type": "integer", - "maximum": 2147483647, - "minimum": 1 - }, - "max_booking_duration_minutes": { - "type": "integer", - "maximum": 2147483647, - "minimum": 1 - }, - "booking_lead_time_minutes": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "max_booking_advance_days": { - "type": "integer", - "maximum": 2147483647, - "minimum": 1 - }, - "buffer_before_minutes": { - "type": "integer", - "maximum": 1440, - "minimum": 0 - }, - "buffer_after_minutes": { - "type": "integer", - "maximum": 1440, - "minimum": 0 - }, - "cancellation_cutoff_hours": { - "type": "integer", - "maximum": 8760, - "minimum": 0 + "format": "uri", + "nullable": true, + "readOnly": true }, - "approval_mode": { - "$ref": "#/components/schemas/ApprovalModeE35Enum" - } - } - }, - "PatchedBookableSpaceWrite": { - "type": "object", - "properties": { - "name": { + "frontend_domain": { "type": "string", - "maxLength": 200 + "nullable": true, + "maxLength": 255 }, - "kind": { + "frontend_domain_status": { "allOf": [ { - "$ref": "#/components/schemas/Kind3bfEnum" + "$ref": "#/components/schemas/FrontendDomainStatusEnum" } ], - "default": "other" + "readOnly": true }, - "description": { + "domain_verified_at": { "type": "string", - "default": "" - }, - "capacity": { - "type": "integer", - "minimum": 0, - "default": 0 + "format": "date-time", + "readOnly": true, + "nullable": true }, - "location": { + "domain_verification_token": { "type": "string", - "default": "", - "maxLength": 255 + "readOnly": true }, - "is_public": { - "type": "boolean", - "default": false + "domain_verification_record": { + "type": "object", + "nullable": true, + "properties": { + "host": { + "type": "string" + }, + "type": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "readOnly": true }, - "show_public_availability": { + "platform_hosting": { "type": "boolean", - "default": false + "readOnly": true }, - "show_public_booker_names": { + "is_platform_subdomain": { "type": "boolean", - "default": false - }, - "custom_form": { - "nullable": true + "readOnly": true }, - "requester_notifications_enabled": { - "type": "boolean", - "nullable": true + "hidden_from_central_directory": { + "type": "boolean" }, - "payment_amount": { + "public_api_key": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "default": "0.00" + "readOnly": true }, - "machine_type": { - "type": "integer", - "minimum": 1, - "nullable": true - } - } - }, - "PatchedBox": { - "type": "object", - "properties": { - "id": { - "type": "integer", + "cors_allowed_origins": { "readOnly": true }, - "makerspace": { - "type": "integer", + "enabled_modules": { + "type": "array", + "items": { + "type": "string" + }, "readOnly": true }, - "parent": { - "type": "integer", - "nullable": true + "unavailable_apps": { + "type": "array", + "items": { + "type": "string" + }, + "readOnly": true }, - "code": { - "type": "string", + "resource_limit_overrides": {}, + "enabled_features": {}, + "theme_config": { + "type": "object", + "additionalProperties": false, + "description": "Public/member brand colours. Labels and light/dark hover, active, soft and link colours are derived automatically with accessible fallbacks. Well-formed #RGB or #RRGGBB colours are accepted; only malformed colour values are rejected. Blank colours use Maker Teal. Staff chrome and status colours stay fixed.", + "properties": { + "mode": { + "type": "string", + "enum": [ + "system", + "light", + "dark" + ] + }, + "primary_color": { + "type": "string", + "pattern": "^$|^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" + }, + "accent_color": { + "type": "string", + "pattern": "^$|^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" + }, + "logo_url": { + "type": "string", + "maxLength": 2048 + } + } + }, + "branding_config": { "readOnly": true }, - "label": { + "public_display_name": { "type": "string", + "writeOnly": true, "maxLength": 200 }, - "location": { + "telegram_group_chat_id": { "type": "string", - "maxLength": 200 + "maxLength": 64 }, - "description": { - "type": "string" + "telegram_bot_token": { + "type": "string", + "writeOnly": true }, - "kit_components": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KitComponent" - }, - "maxItems": 50 + "telegram_bot_token_set": { + "type": "boolean", + "readOnly": true }, - "is_active": { - "type": "boolean" + "smtp_host": { + "type": "string", + "maxLength": 200 }, - "qr_code_id": { + "smtp_port": { "type": "integer", - "nullable": true, - "readOnly": true + "maximum": 2147483647, + "minimum": 0 }, - "created_at": { + "smtp_username": { "type": "string", - "format": "date-time", - "readOnly": true + "maxLength": 200 }, - "updated_at": { + "smtp_password": { "type": "string", - "format": "date-time", - "readOnly": true - } - } - }, - "PatchedCategoryAdmin": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "readOnly": true + "writeOnly": true }, - "makerspace": { - "type": "integer", + "smtp_password_set": { + "type": "boolean", "readOnly": true }, - "name": { - "type": "string", - "maxLength": 100 + "smtp_use_tls": { + "type": "boolean" }, - "slug": { + "smtp_use_ssl": { + "type": "boolean" + }, + "smtp_from_email": { "oneOf": [ { "type": "string", - "pattern": "^[-a-zA-Z0-9_]+$" + "format": "email", + "maxLength": 254 }, { "type": "string", @@ -91230,32 +113205,39 @@ } ] }, - "display_order": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "slack_webhook_url": { + "type": "string", + "writeOnly": true, + "maxLength": 2048 }, - "loan_days": { - "type": "integer", - "maximum": 365, - "minimum": 1, - "nullable": true + "slack_webhook_url_set": { + "type": "boolean", + "readOnly": true }, - "max_renewals": { - "type": "integer", - "maximum": 100, - "minimum": 0, - "nullable": true + "mattermost_webhook_url": { + "type": "string", + "writeOnly": true, + "maxLength": 2048 }, - "icon": { + "mattermost_webhook_url_set": { + "type": "boolean", + "readOnly": true + }, + "discord_webhook_url": { "type": "string", - "maxLength": 50 + "writeOnly": true, + "maxLength": 2048 }, - "product_count": { + "discord_webhook_url_set": { + "type": "boolean", + "readOnly": true + }, + "default_loan_days": { "type": "integer", - "readOnly": true, - "default": 0 + "maximum": 2147483647, + "minimum": 0 }, + "presence_preset_minutes": {}, "created_at": { "type": "string", "format": "date-time", @@ -91268,464 +113250,697 @@ } } }, - "PatchedCertificationTypeUpdate": { + "PatchedMakerspacePaymentSettings": { "type": "object", "properties": { - "id": { - "type": "integer", + "default_currency": { + "type": "string", + "maxLength": 3 + }, + "stripe_publishable_key": { + "type": "string", + "writeOnly": true, + "maxLength": 255 + }, + "stripe_publishable_key_set": { + "type": "boolean", "readOnly": true }, - "machine_type": { - "type": "integer", + "stripe_secret_key": { + "type": "string", + "writeOnly": true + }, + "stripe_secret_key_set": { + "type": "boolean", "readOnly": true }, - "name": { + "stripe_webhook_secret": { "type": "string", - "maxLength": 200 + "writeOnly": true }, - "description": { - "type": "string" + "stripe_webhook_secret_set": { + "type": "boolean", + "readOnly": true }, - "validity_days": { - "type": "integer", - "maximum": 2147483647, - "minimum": 1, + "connect_account_id": { + "type": "string", + "readOnly": true, "nullable": true }, - "is_required_for_service": { - "type": "boolean" + "connect_status": { + "allOf": [ + { + "$ref": "#/components/schemas/ConnectStatusEnum" + } + ], + "readOnly": true }, - "is_required_for_booking": { - "type": "boolean" + "connect_charges_enabled": { + "type": "boolean", + "readOnly": true }, - "is_active": { + "connect_payouts_enabled": { "type": "boolean", "readOnly": true }, - "created_at": { + "connect_status_updated_at": { "type": "string", "format": "date-time", "readOnly": true }, - "updated_at": { + "effective_mode": { "type": "string", - "format": "date-time", + "readOnly": true + }, + "loan_deposit_mode": { + "$ref": "#/components/schemas/LoanDepositModeEnum" + }, + "loan_deposit_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "loan_late_fee_per_day": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "loan_late_fee_cap": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "loan_grace_days": { + "type": "integer", + "maximum": 32767, + "minimum": 0 + }, + "loan_deposit_blocks_issue": { + "type": "boolean" + } + } + }, + "PatchedMakerspaceProfile": { + "type": "object", + "properties": { + "timezone": { + "type": "string", + "maxLength": 100 + }, + "country": { + "type": "string", + "maxLength": 2 + }, + "opening_hours": { + "oneOf": [ + { + "type": "object", + "maxProperties": 0 + }, + { + "type": "object", + "required": [ + "weekly", + "exceptions" + ], + "additionalProperties": false, + "properties": { + "weekly": { + "oneOf": [ + { + "type": "object", + "maxProperties": 0 + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ], + "properties": { + "mon": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "tue": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "wed": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "thu": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "fri": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "sat": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "sun": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + } + } + } + ] + }, + "exceptions": { + "type": "object", + "maxProperties": 366, + "additionalProperties": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "description": "YYYY-MM-DD keys; each value replaces that date's weekly hours." + } + } + } + ], + "description": "Empty object means always open. Empty weekly hours mean always open except dated overrides. Empty interval lists mean closed. Sorted intervals use local wall time; split overnight hours across days." + }, + "default_currency": { + "type": "string", + "maxLength": 3 + }, + "timezone_choices": { + "type": "array", + "items": { + "type": "string" + }, + "readOnly": true + }, + "country_choices": { + "type": "array", + "items": { + "type": "string" + }, "readOnly": true } } }, - "PatchedDeviceUpdate": { + "PatchedMaterial": { "type": "object", "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "machine_type": { + "type": "integer" + }, "name": { "type": "string", - "maxLength": 120 + "maxLength": 100 }, - "offline_threshold_seconds": { - "type": "integer", - "maximum": 604800, - "minimum": 1 + "colours": {}, + "is_active": { + "type": "boolean" } } }, - "PatchedEmailTemplateUpdate": { + "PatchedMemberCardOwnUpdate": { "type": "object", "properties": { - "subject": { + "printed_name": { "type": "string", "maxLength": 200 + } + } + }, + "PatchedMembershipCapabilities": { + "type": "object", + "properties": { + "can_refer": { + "type": "boolean" }, - "text_body": { - "type": "string" - }, - "html_body": { - "type": "string" - }, - "is_active": { + "can_verify": { "type": "boolean" } } }, - "PatchedEventSeriesWrite": { + "PatchedMembershipPlan": { "type": "object", "properties": { - "registration_audience": { - "$ref": "#/components/schemas/EventSeriesWriteRegistrationAudienceEnum" - }, - "ticket_types": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TicketInput" - }, - "writeOnly": true - }, - "title": { - "type": "string", - "maxLength": 200 - }, - "description": { - "type": "string", - "default": "" + "id": { + "type": "integer", + "readOnly": true }, - "location": { + "name": { "type": "string", - "default": "", - "maxLength": 255 - }, - "location_kind": { - "allOf": [ - { - "$ref": "#/components/schemas/LocationKindEnum" - } - ], - "default": "other" + "maxLength": 120 }, - "custom_form": { - "nullable": true + "interval": { + "$ref": "#/components/schemas/IntervalEnum" }, - "capacity": { + "custom_days": { "type": "integer", + "maximum": 2147483647, "minimum": 0, - "default": 0 + "nullable": true }, - "payment_amount": { + "amount": { "type": "string", "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "default": "0.00" + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "registration_requires_approval": { - "type": "boolean", - "default": false + "currency": { + "type": "string", + "maxLength": 3 }, - "cancellation_cutoff_hours": { + "is_active": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "loan_max_active": { "type": "integer", - "maximum": 8760, + "maximum": 2147483647, "minimum": 0, "nullable": true }, - "registration_cutoff_lead_minutes": { + "loan_max_items": { "type": "integer", + "maximum": 2147483647, "minimum": 0, "nullable": true }, - "is_public": { - "type": "boolean", - "default": false - }, - "recurrence_timezone": { - "type": "string", - "maxLength": 64 - }, - "dtstart_local_date": { - "type": "string", - "format": "date" + "auto_renew": { + "type": "boolean" }, - "dtstart_local_time": { - "type": "string", - "format": "time" + "renewal_open_days": { + "type": "integer", + "maximum": 365, + "minimum": 0 }, - "recurrence_rule": { - "type": "string", - "maxLength": 500 + "renewal_grace_days": { + "type": "integer", + "maximum": 365, + "minimum": 0 }, - "duration_minutes": { + "renewal_retries": { "type": "integer", - "minimum": 1 + "maximum": 3, + "minimum": 0 }, - "effective_from": { - "type": "string", - "format": "date-time", - "writeOnly": true - } + "reminder_days_before": {}, + "reminder_days_after": {} } }, - "PatchedEventWrite": { + "PatchedMembershipRoleAssign": { "type": "object", "properties": { - "registration_audience": { - "$ref": "#/components/schemas/EventDefaultRegistrationAudienceEnum" - }, - "category_id": { + "role_id": { "type": "integer", - "minimum": 1, - "nullable": true + "description": "Single-role set; omit role_ids." }, - "tags": { + "role_ids": { "type": "array", "items": { - "type": "string", - "maxLength": 50 + "type": "integer" }, - "maxItems": 20 - }, - "title": { - "type": "string", - "maxLength": 200 - }, - "description": { - "type": "string", - "default": "" - }, - "starts_at": { - "type": "string", - "format": "date-time" + "description": "Complete role set. Space Manager and Member must each be assigned alone." + } + } + }, + "PatchedMessagingSettings": { + "type": "object", + "properties": { + "use_platform_sender": { + "type": "boolean" }, - "ends_at": { - "type": "string", - "format": "date-time" + "sms_account_sid": { + "oneOf": [ + { + "type": "string", + "pattern": "^(AC[0-9a-fA-F]{32})?$" + }, + { + "type": "string", + "maxLength": 0 + } + ] }, - "timezone_name": { + "sms_from_number": { "type": "string", - "maxLength": 64 + "maxLength": 32 }, - "location": { + "sms_auth_token": { "type": "string", - "default": "", - "maxLength": 255 + "writeOnly": true, + "maxLength": 4096 }, - "location_kind": { - "allOf": [ + "whatsapp_phone_number_id": { + "oneOf": [ { - "$ref": "#/components/schemas/LocationKindEnum" + "type": "string", + "pattern": "^\\d{1,64}$" + }, + { + "type": "string", + "maxLength": 0 } - ], - "default": "other" + ] }, - "custom_form": { - "nullable": true + "whatsapp_business_account_id": { + "oneOf": [ + { + "type": "string", + "pattern": "^\\d{1,64}$" + }, + { + "type": "string", + "maxLength": 0 + } + ] }, - "capacity": { - "type": "integer", - "minimum": 0, - "default": 0 + "whatsapp_access_token": { + "type": "string", + "writeOnly": true, + "maxLength": 8192 }, - "payment_amount": { + "whatsapp_app_secret": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "default": "0.00" + "writeOnly": true, + "maxLength": 4096 }, - "is_public": { + "sms_auth_token_set": { "type": "boolean", - "default": false + "readOnly": true }, - "registration_requires_approval": { + "whatsapp_access_token_set": { "type": "boolean", - "default": false + "readOnly": true }, - "cancellation_cutoff_hours": { - "type": "integer", - "maximum": 8760, - "minimum": 0, - "nullable": true, - "description": "Member cancellation closes this many hours before start; null means start." + "whatsapp_app_secret_set": { + "type": "boolean", + "readOnly": true }, - "registration_cutoff_at": { + "whatsapp_webhook_verify_token": { "type": "string", - "format": "date-time", - "nullable": true + "readOnly": true }, - "registration_cutoff_lead_minutes": { - "type": "integer", - "minimum": 0, - "nullable": true + "effective_source": { + "type": "object", + "additionalProperties": { + "enum": [ + "own", + "platform", + "none" + ], + "type": "string", + "description": "* `own` - own\n* `platform` - platform\n* `none` - none" + }, + "readOnly": true }, - "inherit_fields": { + "platform_available": { + "type": "object", + "additionalProperties": { + "type": "boolean" + }, + "readOnly": true + }, + "webhook_urls": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "readOnly": true + } + } + }, + "PatchedNotificationRecipientsPatch": { + "type": "object", + "properties": { + "recipients": { "type": "array", "items": { - "$ref": "#/components/schemas/InheritFieldsEnum" - }, - "writeOnly": true + "$ref": "#/components/schemas/NotificationRecipientUpdate" + } } } }, - "PatchedEvidenceRetentionPatch": { + "PatchedNotificationRulesPatch": { "type": "object", - "description": "True partial update: supply any subset; an empty body is rejected.", "properties": { - "object_retention_days": { - "type": "integer", - "maximum": 3650, - "minimum": 30, - "nullable": true - }, - "abandoned_upload_cleanup_enabled": { - "type": "boolean" + "changes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationRuleChange" + } }, - "abandoned_upload_retention_days": { - "type": "integer", - "maximum": 3650, - "minimum": 1, - "nullable": true + "preferences": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationPreferenceChange" + } } } }, - "PatchedInventoryAssetAdminUpdate": { + "PatchedOrganizationMembershipUpdate": { "type": "object", "properties": { - "asset_tag": { - "type": "string", - "maxLength": 100 - }, - "serial_number": { - "type": "string", - "maxLength": 100 - }, - "box": { - "type": "integer", - "nullable": true + "status": { + "$ref": "#/components/schemas/StatusE75Enum" }, - "notes": { - "type": "string" + "governance_actions": { + "type": "array", + "items": { + "type": "string" + } }, - "public_self_checkout_enabled": { - "type": "boolean" + "granted_actions": { + "type": "array", + "items": { + "type": "string" + } } } }, - "PatchedInventoryProductAdminUpdate": { + "PatchedOrganizationProfileUpdate": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "makerspace": { - "type": "integer", - "readOnly": true - }, - "box": { - "type": "integer", - "nullable": true - }, - "category": { - "type": "integer", - "nullable": true - }, "name": { "type": "string", "maxLength": 200 }, - "manufacturer": { - "type": "string", - "maxLength": 200 - }, - "model": { - "type": "string", - "maxLength": 200 - }, - "sku": { - "type": "string", - "maxLength": 100 - }, - "barcode": { - "type": "string", - "maxLength": 100 - }, - "supplier": { + "slug": { "type": "string", - "maxLength": 200 - }, - "reorder_point": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "reorder_quantity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "kit_components": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KitComponent" - }, - "maxItems": 50 + "maxLength": 50, + "pattern": "^[-a-zA-Z0-9_]+$" }, "description": { "type": "string" }, - "borrowing_terms": { - "type": "string", - "maxLength": 2000 - }, - "image_key": { - "type": "string", - "readOnly": true - }, - "image_url": { - "type": "string", - "format": "uri", - "nullable": true, - "readOnly": true - }, - "tracking_mode": { - "$ref": "#/components/schemas/TrackingModeB86Enum" - }, - "loan_days": { - "type": "integer", - "maximum": 365, - "minimum": 1, - "nullable": true - }, - "max_renewals": { - "type": "integer", - "maximum": 100, - "minimum": 0, - "nullable": true - }, - "total_quantity": { - "type": "integer", - "readOnly": true - }, - "available_quantity": { - "type": "integer", - "readOnly": true - }, - "reserved_quantity": { - "type": "integer", - "readOnly": true - }, - "issued_quantity": { - "type": "integer", - "readOnly": true - }, - "damaged_quantity": { - "type": "integer", - "readOnly": true - }, - "lost_quantity": { - "type": "integer", - "readOnly": true - }, - "needs_fix_quantity": { - "type": "integer", - "readOnly": true - }, - "is_public": { - "type": "boolean" + "website": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "maxLength": 200 + }, + { + "type": "string", + "maxLength": 0 + } + ] }, - "public_self_checkout_enabled": { + "public_profile_enabled": { "type": "boolean" - }, - "show_public_count": { + } + } + }, + "PatchedPlatformBackupSettings": { + "type": "object", + "properties": { + "automatic_backups_enabled": { "type": "boolean" }, - "public_availability_mode": { - "$ref": "#/components/schemas/PublicAvailabilityModeFc2Enum" + "retention_days": { + "type": "integer", + "maximum": 3650, + "minimum": 1 }, - "storage_location": { + "last_scheduled_at": { "type": "string", - "maxLength": 200 + "format": "date-time", + "readOnly": true, + "nullable": true }, - "is_archived": { - "type": "boolean" + "last_success_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true }, - "created_at": { + "last_error": { "type": "string", - "format": "date-time", "readOnly": true }, "updated_at": { @@ -91735,58 +113950,46 @@ } } }, - "PatchedMachine": { + "PatchedPlatformEmailSettings": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "makerspace": { - "type": "integer", - "readOnly": true - }, - "machine_type": { - "allOf": [ - { - "$ref": "#/components/schemas/MachineType" - } - ], - "readOnly": true + "smtp_host": { + "type": "string", + "maxLength": 200 }, - "machine_type_id": { + "smtp_port": { "type": "integer", - "writeOnly": true + "maximum": 2147483647, + "minimum": 0 }, - "name": { + "smtp_username": { "type": "string", "maxLength": 200 }, - "location": { + "smtp_password": { "type": "string", - "maxLength": 200 - }, - "notes": { - "type": "string" + "writeOnly": true }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/MachineStatusEnum" - } - ], + "smtp_password_set": { + "type": "boolean", "readOnly": true }, - "firmware_version": { - "type": "string", - "maxLength": 100 + "smtp_use_tls": { + "type": "boolean" }, - "camera_feed_url": { + "smtp_use_ssl": { + "type": "boolean" + }, + "from_email": { "oneOf": [ { "type": "string", - "format": "uri", - "maxLength": 200 + "format": "email", + "maxLength": 254 }, { "type": "string", @@ -91794,694 +113997,549 @@ } ] }, - "status_since": { + "updated_at": { "type": "string", "format": "date-time", "readOnly": true - }, - "public_fields": {}, - "type_payload": {}, - "image_url": { - "type": "string", - "nullable": true, - "readOnly": true - }, - "warranty_status": { - "type": "string", - "readOnly": true - }, - "is_public": { - "type": "boolean", - "readOnly": true - }, - "is_active": { - "type": "boolean", - "readOnly": true - }, - "usage_hours": { - "type": "number", - "format": "double", - "readOnly": true - }, - "can_operate": { - "type": "boolean", - "readOnly": true - }, - "can_edit": { + } + } + }, + "PatchedPlatformSocialAuthSettings": { + "type": "object", + "properties": { + "google_enabled": { "type": "boolean", "readOnly": true }, - "can_delegate": { + "apple_enabled": { "type": "boolean", "readOnly": true }, - "can_retire": { + "github_enabled": { "type": "boolean", "readOnly": true }, - "can_unretire": { - "type": "boolean", - "readOnly": true + "google_web_client_id": { + "type": "string", + "maxLength": 255 }, - "can_manage": { - "type": "boolean", - "readOnly": true + "google_ios_client_id": { + "type": "string", + "maxLength": 255 }, - "created_at": { + "google_android_client_id": { "type": "string", - "format": "date-time", - "readOnly": true + "maxLength": 255 }, - "updated_at": { + "apple_service_id": { "type": "string", - "format": "date-time", - "readOnly": true - } - } - }, - "PatchedMachinePublicity": { - "type": "object", - "properties": { - "is_public": { - "type": "boolean" - } - } - }, - "PatchedMachineTypeUpdate": { - "type": "object", - "properties": { - "name": { + "maxLength": 255 + }, + "apple_native_app_ids": {}, + "apple_team_id": { "type": "string", - "maxLength": 200 + "maxLength": 64 }, - "icon": { + "apple_key_id": { "type": "string", - "maxLength": 50 + "maxLength": 64 }, - "capability_config": {} - } - }, - "PatchedMaintenanceScheduleWrite": { - "type": "object", - "properties": { - "description": { - "type": "string" + "apple_private_key": { + "type": "string", + "writeOnly": true }, - "interval_days": { - "type": "integer", - "minimum": 1 + "apple_private_key_set": { + "type": "boolean", + "readOnly": true }, - "next_due": { + "github_client_id": { "type": "string", - "format": "date" - }, - "trigger": { - "$ref": "#/components/schemas/MaintenanceScheduleWriteTriggerEnum" + "maxLength": 255 }, - "usage_hours": { + "github_client_secret": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "nullable": true + "writeOnly": true, + "maxLength": 4096 }, - "job_count": { - "type": "integer", - "minimum": 1, - "nullable": true + "github_client_secret_set": { + "type": "boolean", + "readOnly": true }, - "anchor": { - "$ref": "#/components/schemas/AnchorEnum" + "clear_github_client_secret": { + "type": "boolean", + "writeOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true } } }, - "PatchedMakerspace": { + "PatchedPlatformStripeConnectSettings": { "type": "object", "properties": { - "creation_setup": { - "allOf": [ - { - "$ref": "#/components/schemas/MakerspaceCreationSetup" - } - ], - "writeOnly": true - }, - "creation_invitation": { - "allOf": [ - { - "$ref": "#/components/schemas/CreationInviteDelivery" - } - ], - "readOnly": true, - "nullable": true - }, - "install_profile_id": { - "type": "integer", - "minimum": 1, - "writeOnly": true - }, "id": { "type": "integer", "readOnly": true }, - "name": { - "type": "string", - "maxLength": 200 - }, - "public_code": { + "stripe_publishable_key": { "type": "string", - "pattern": "^[A-Z0-9]{4}$", - "maxLength": 4 + "writeOnly": true, + "maxLength": 255 }, - "slug": { - "type": "string", - "maxLength": 50, - "pattern": "^[-a-zA-Z0-9_]+$" + "stripe_publishable_key_set": { + "type": "boolean", + "readOnly": true }, - "location": { + "stripe_secret_key": { "type": "string", - "maxLength": 200 + "writeOnly": true }, - "timezone": { - "type": "string", + "stripe_secret_key_set": { + "type": "boolean", "readOnly": true }, - "country": { + "stripe_webhook_secret": { "type": "string", - "readOnly": true + "writeOnly": true }, - "opening_hours": { - "oneOf": [ - { - "type": "object", - "maxProperties": 0 - }, - { - "type": "object", - "required": [ - "weekly", - "exceptions" - ], - "additionalProperties": false, - "properties": { - "weekly": { - "oneOf": [ - { - "type": "object", - "maxProperties": 0 - }, - { - "type": "object", - "additionalProperties": false, - "required": [ - "mon", - "tue", - "wed", - "thu", - "fri", - "sat", - "sun" - ], - "properties": { - "mon": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "tue": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "wed": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "thu": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "fri": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "sat": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "sun": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - } - } - } - ] - }, - "exceptions": { - "type": "object", - "maxProperties": 366, - "additionalProperties": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "description": "YYYY-MM-DD keys; each value replaces that date's weekly hours." - } - } - } - ], - "description": "Empty object means always open. Empty weekly hours mean always open except dated overrides. Empty interval lists mean closed. Sorted intervals use local wall time; split overnight hours across days.", + "stripe_webhook_secret_set": { + "type": "boolean", "readOnly": true }, - "map_url": { - "oneOf": [ - { - "type": "string", - "format": "uri", - "maxLength": 200 - }, - { - "type": "string", - "maxLength": 0 - } - ] - }, - "geofence_latitude": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,3}(?:\\.\\d{0,6})?$", - "nullable": true - }, - "geofence_longitude": { + "stripe_connect_client_id": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,3}(?:\\.\\d{0,6})?$", - "nullable": true + "maxLength": 255 }, - "geofence_radius_m": { + "application_fee_bps": { "type": "integer", - "maximum": 2147483647, - "minimum": 1 + "maximum": 10000, + "minimum": 0 + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + } + }, + "PatchedPreferenceWrite": { + "type": "object", + "properties": { + "feature": { + "$ref": "#/components/schemas/Feature9d8Enum" }, - "geofence_enabled": { - "type": "boolean" + "channel": { + "$ref": "#/components/schemas/ChannelB6fEnum" }, - "public_inventory_enabled": { + "enabled": { "type": "boolean" }, - "public_site_enabled": { + "daily_digest": { + "type": "boolean" + } + } + }, + "PatchedPrinterPoolVisibility": { + "type": "object", + "properties": { + "is_public": { "type": "boolean" + } + } + }, + "PatchedRegistrationAnswers": { + "type": "object", + "properties": { + "custom_answers": { + "nullable": true + } + } + }, + "PatchedReportSchedule": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true }, - "public_landing_page": { - "$ref": "#/components/schemas/PublicLandingPage6a0Enum" + "makerspace": { + "type": "integer", + "readOnly": true }, - "public_stats_enabled": { - "type": "boolean" + "report_key": { + "$ref": "#/components/schemas/ReportKeyEnum" }, - "public_stats_show_holder_names": { - "type": "boolean" + "filters": { + "$ref": "#/components/schemas/ReportScheduleFilters" }, - "public_print_status_lookup_policy": { - "$ref": "#/components/schemas/PublicPrintStatusLookupPolicyEnum" + "grain": { + "type": "string", + "maxLength": 8 }, - "membership_policy": { - "$ref": "#/components/schemas/MembershipPolicyEnum" + "format": { + "$ref": "#/components/schemas/ReportScheduleFormatEnum" }, - "membership_dues_amount": { + "cadence": { + "$ref": "#/components/schemas/CadenceEnum" + }, + "next_run_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "format": "date-time" }, - "referrals_enabled": { - "type": "boolean" + "last_run_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true }, - "lapsed_members_cannot_borrow": { + "is_active": { "type": "boolean" }, - "anonymous_requests_enabled": { - "type": "boolean" + "destination": { + "type": "integer", + "nullable": true }, - "request_access": { + "recipient_emails": { + "type": "array", + "items": { + "type": "string", + "format": "email" + }, + "maxItems": 10 + }, + "created_by": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { "type": "string", + "format": "date-time", "readOnly": true }, - "filament_low_stock_threshold_grams": { + "updated_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$" - }, - "superadmin_access_enabled": { - "type": "boolean" + "format": "date-time", + "readOnly": true }, - "archive_custody_state": { + "last_delivery": { "allOf": [ { - "$ref": "#/components/schemas/ArchiveCustodyStateEnum" + "$ref": "#/components/schemas/ReportDelivery" } ], "nullable": true, "readOnly": true }, - "staff_notifications_enabled": { - "type": "boolean" - }, - "booking_requester_notifications_enabled": { - "type": "boolean" + "condition": { + "$ref": "#/components/schemas/ReportCondition" }, - "event_default_registration_audience": { - "$ref": "#/components/schemas/EventDefaultRegistrationAudienceEnum" + "recipient_roles": { + "type": "array", + "items": { + "type": "string", + "maxLength": 80, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "maxItems": 20 }, - "event_reminder_lead_hours": { + "recipient_actions": { "type": "array", "items": { - "type": "integer", - "maximum": 8760, - "minimum": 1 + "$ref": "#/components/schemas/RecipientActionsEnum" }, - "maxItems": 100 + "maxItems": 20 + } + } + }, + "PatchedReturnPolicy": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true }, - "booking_reminder_lead_hours": { + "default_loan_days": { + "type": "integer", + "maximum": 365, + "minimum": 1 + }, + "loan_renewal_mode": { + "$ref": "#/components/schemas/LoanRenewalModeEnum" + }, + "loan_max_renewals": { + "type": "integer", + "maximum": 100, + "minimum": 0 + }, + "loan_renewal_days": { + "type": "integer", + "maximum": 365, + "minimum": 1 + }, + "loan_max_active": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "loan_max_items": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "loan_reminder_offsets_hours": { "type": "array", "items": { "type": "integer", - "maximum": 8760, - "minimum": 1 + "minimum": -8760, + "maximum": 8760 }, - "maxItems": 100 + "maxItems": 100, + "uniqueItems": true, + "description": "Signed whole hours from due time: negative before, zero due, positive overdue." }, - "logo_key": { - "type": "string", - "readOnly": true + "loan_escalate_overdue_days": { + "type": "integer", + "maximum": 365, + "minimum": 0 + } + } + }, + "PatchedRoleSetInput": { + "type": "object", + "properties": { + "role_id": { + "type": "integer", + "description": "Single-role set; omit role_ids." }, - "logo_url": { - "type": "string", - "format": "uri", - "nullable": true, + "role_ids": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Complete role set. Space Manager and Member must each be assigned alone." + } + } + }, + "PatchedSavedReportView": { + "type": "object", + "properties": { + "id": { + "type": "integer", "readOnly": true }, - "cover_image_key": { - "type": "string", + "makerspace": { + "type": "integer", "readOnly": true }, - "cover_image_url": { - "type": "string", - "format": "uri", - "nullable": true, + "owner": { + "type": "integer", "readOnly": true }, - "frontend_domain": { + "name": { "type": "string", - "nullable": true, - "maxLength": 255 + "maxLength": 80 }, - "frontend_domain_status": { - "allOf": [ - { - "$ref": "#/components/schemas/FrontendDomainStatusEnum" - } - ], - "readOnly": true + "state": { + "$ref": "#/components/schemas/ReportViewState" }, - "domain_verified_at": { + "shared": { + "type": "boolean" + }, + "created_at": { "type": "string", "format": "date-time", - "readOnly": true, - "nullable": true + "readOnly": true }, - "domain_verification_token": { + "updated_at": { "type": "string", + "format": "date-time", + "readOnly": true + } + } + }, + "PatchedSavedView": { + "type": "object", + "properties": { + "id": { + "type": "integer", "readOnly": true }, - "domain_verification_record": { - "type": "object", - "nullable": true, - "properties": { - "host": { - "type": "string" - }, - "type": { - "type": "string" - }, - "value": { - "type": "string" - } - }, + "makerspace": { + "type": "integer", "readOnly": true }, - "platform_hosting": { - "type": "boolean", + "owner": { + "type": "integer", "readOnly": true }, - "is_platform_subdomain": { - "type": "boolean", + "owner_username": { + "type": "string", "readOnly": true }, - "hidden_from_central_directory": { + "name": { + "type": "string", + "maxLength": 80 + }, + "entity": { + "$ref": "#/components/schemas/SavedViewEntityEnum" + }, + "state": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 250 + } + }, + "shared": { "type": "boolean" }, - "public_api_key": { + "created_at": { "type": "string", + "format": "date-time", "readOnly": true }, - "cors_allowed_origins": { + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + } + }, + "PatchedSchedule": { + "type": "object", + "properties": { + "id": { + "type": "integer", "readOnly": true }, - "enabled_modules": { + "name": { + "type": "string", + "maxLength": 200 + }, + "container": { + "type": "integer", + "nullable": true + }, + "interval_days": { + "type": "integer", + "minimum": 1 + }, + "next_due": { + "type": "string", + "format": "date" + }, + "blind": { + "type": "boolean" + }, + "is_active": { + "type": "boolean" + } + } + }, + "PatchedStaffGrantPermissions": { + "type": "object", + "properties": { + "permissions": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/Permissions5d1Enum" }, - "readOnly": true + "maxItems": 5, + "minItems": 1 + } + } + }, + "PatchedTimingPolicy": { + "type": "object", + "properties": { + "quiet_start": { + "type": "string", + "format": "time" }, - "unavailable_apps": { + "quiet_end": { + "type": "string", + "format": "time" + }, + "channels": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/ChannelsEnum" }, - "readOnly": true + "maxItems": 9 }, - "resource_limit_overrides": {}, - "enabled_features": {}, - "theme_config": { - "type": "object", - "additionalProperties": false, - "description": "Public/member secondary colours. Foreground, hover/pressed states and light/dark link ink are derived automatically. Rejects palettes below 4.5:1 text or 3:1 component contrast in either mode (including 80% counter text and tinted surfaces). Blank colours use the product palette. Staff chrome and status colours stay fixed.", - "properties": { - "mode": { - "type": "string", - "enum": [ - "system", - "light", - "dark" - ] - }, - "primary_color": { - "type": "string", - "pattern": "^$|^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" - }, - "accent_color": { + "digest_at": { + "type": "string", + "format": "time" + }, + "daily_digest_default": { + "type": "boolean" + }, + "bounce_secret": { + "writeOnly": true, + "oneOf": [ + { "type": "string", - "pattern": "^$|^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" + "maxLength": 200, + "minLength": 32 }, - "logo_url": { + { "type": "string", - "maxLength": 2048 + "maxLength": 0 } - } + ] }, - "branding_config": { + "bounce_configured": { + "type": "boolean", + "readOnly": true + } + } + }, + "PatchedToBuyItem": { + "type": "object", + "properties": { + "id": { + "type": "integer", "readOnly": true }, - "public_display_name": { - "type": "string", - "writeOnly": true, - "maxLength": 200 - }, - "telegram_group_chat_id": { - "type": "string", - "maxLength": 64 - }, - "telegram_bot_token": { - "type": "string", - "writeOnly": true - }, - "telegram_bot_token_set": { - "type": "boolean", + "makerspace": { + "type": "integer", "readOnly": true }, - "smtp_host": { - "type": "string", - "maxLength": 200 + "kind": { + "allOf": [ + { + "$ref": "#/components/schemas/ToBuyItemKindEnum" + } + ], + "readOnly": true }, - "smtp_port": { + "machine_type": { "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "nullable": true }, - "smtp_username": { + "machine_type_name": { "type": "string", - "maxLength": 200 + "readOnly": true, + "nullable": true }, - "smtp_password": { + "name": { "type": "string", - "writeOnly": true - }, - "smtp_password_set": { - "type": "boolean", - "readOnly": true - }, - "smtp_use_tls": { - "type": "boolean" + "maxLength": 200 }, - "smtp_use_ssl": { - "type": "boolean" + "quantity": { + "type": "integer", + "minimum": 1, + "default": 1 }, - "smtp_from_email": { + "link": { "oneOf": [ { "type": "string", - "format": "email", - "maxLength": 254 + "format": "uri", + "maxLength": 200 }, { "type": "string", @@ -92489,39 +114547,92 @@ } ] }, - "slack_webhook_url": { + "status": { + "$ref": "#/components/schemas/ToBuyItemStatusEnum" + }, + "estimated_unit_cost": { "type": "string", - "writeOnly": true, - "maxLength": 2048 + "format": "decimal", + "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$", + "nullable": true + }, + "vendor_name": { + "type": "string", + "maxLength": 200 + }, + "actual_unit_cost": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$", + "nullable": true + }, + "purchaser": { + "type": "integer", + "readOnly": true + }, + "purchaser_username": { + "type": "string", + "readOnly": true + }, + "ordered_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "received_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "moved_to_inventory_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "resulting_product": { + "type": "integer", + "readOnly": true, + "nullable": true }, - "slack_webhook_url_set": { - "type": "boolean", - "readOnly": true + "resulting_pool": { + "type": "integer", + "readOnly": true, + "nullable": true }, - "mattermost_webhook_url": { - "type": "string", - "writeOnly": true, - "maxLength": 2048 + "resulting_machine": { + "type": "integer", + "readOnly": true, + "nullable": true }, - "mattermost_webhook_url_set": { - "type": "boolean", - "readOnly": true + "source_pool": { + "type": "integer", + "readOnly": true, + "nullable": true }, - "discord_webhook_url": { - "type": "string", - "writeOnly": true, - "maxLength": 2048 + "source_product": { + "type": "integer", + "readOnly": true, + "nullable": true }, - "discord_webhook_url_set": { - "type": "boolean", + "receipts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ToBuyReceipt" + }, "readOnly": true }, - "default_loan_days": { + "created_by": { "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "readOnly": true, + "nullable": true + }, + "created_by_username": { + "type": "string", + "readOnly": true }, - "presence_preset_minutes": {}, "created_at": { "type": "string", "format": "date-time", @@ -92534,421 +114645,402 @@ } } }, - "PatchedMakerspacePaymentSettings": { + "PatchedWhatsAppBinding": { "type": "object", "properties": { - "default_currency": { + "id": { + "type": "integer", + "readOnly": true + }, + "feature": { "type": "string", - "maxLength": 3 + "maxLength": 32 }, - "stripe_publishable_key": { + "event": { "type": "string", - "writeOnly": true, - "maxLength": 255 + "maxLength": 64 }, - "stripe_publishable_key_set": { - "type": "boolean", - "readOnly": true + "audience": { + "$ref": "#/components/schemas/Audience356Enum" }, - "stripe_secret_key": { + "business_account_id": { "type": "string", - "writeOnly": true - }, - "stripe_secret_key_set": { - "type": "boolean", "readOnly": true }, - "stripe_webhook_secret": { + "template_name": { "type": "string", - "writeOnly": true - }, - "stripe_webhook_secret_set": { - "type": "boolean", - "readOnly": true + "maxLength": 512 }, - "connect_account_id": { + "language": { "type": "string", - "readOnly": true, - "nullable": true + "maxLength": 16 }, - "connect_status": { - "allOf": [ - { - "$ref": "#/components/schemas/ConnectStatusEnum" - } - ], - "readOnly": true + "parameters": {}, + "is_active": { + "type": "boolean" }, - "connect_charges_enabled": { + "needs_review": { "type": "boolean", "readOnly": true }, - "connect_payouts_enabled": { - "type": "boolean", - "readOnly": true + "components": { + "writeOnly": true + } + } + }, + "PaymentAgeingRow": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer" }, - "connect_status_updated_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "currency": { + "type": "string" }, - "effective_mode": { - "type": "string", - "readOnly": true + "age_bucket": { + "$ref": "#/components/schemas/AgeBucketEnum" }, - "loan_deposit_mode": { - "$ref": "#/components/schemas/LoanDepositModeEnum" + "payment_count": { + "type": "integer" }, - "loan_deposit_amount": { + "outstanding_amount": { "type": "string", "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$" + } + }, + "required": [ + "age_bucket", + "currency", + "outstanding_amount", + "payment_count" + ] + }, + "PaymentBulkAction": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + } + } + }, + "required": [ + "ids" + ] + }, + "PaymentBulkOffline": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + } }, - "loan_late_fee_per_day": { + "settlement": { + "$ref": "#/components/schemas/ManualSettlement" + } + }, + "required": [ + "ids", + "settlement" + ] + }, + "PaymentCashBook": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PaymentCashBookRow" + } + } + }, + "required": [ + "rows", + "typed_rows" + ] + }, + "PaymentCashBookRow": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer" + }, + "currency": { + "type": "string" + }, + "method": { + "type": "string" + }, + "collected_amount": { "type": "string", "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" }, - "loan_late_fee_cap": { + "refunded_amount": { "type": "string", "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - }, - "loan_grace_days": { - "type": "integer", - "maximum": 32767, - "minimum": 0 + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" }, - "loan_deposit_blocks_issue": { - "type": "boolean" + "net_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" } - } + }, + "required": [ + "collected_amount", + "currency", + "method", + "net_amount", + "refunded_amount" + ] }, - "PatchedMakerspaceProfile": { + "PaymentDocument": { "type": "object", "properties": { - "timezone": { + "id": { + "type": "integer", + "readOnly": true + }, + "payment": { + "type": "integer" + }, + "number": { "type": "string", - "maxLength": 100 + "maxLength": 80 }, - "country": { + "kind": { + "$ref": "#/components/schemas/PaymentDocumentKindEnum" + }, + "issued_at": { "type": "string", - "maxLength": 2 + "format": "date-time", + "readOnly": true }, - "opening_hours": { - "oneOf": [ - { - "type": "object", - "maxProperties": 0 - }, - { - "type": "object", - "required": [ - "weekly", - "exceptions" - ], - "additionalProperties": false, - "properties": { - "weekly": { - "oneOf": [ - { - "type": "object", - "maxProperties": 0 - }, - { - "type": "object", - "additionalProperties": false, - "required": [ - "mon", - "tue", - "wed", - "thu", - "fri", - "sat", - "sun" - ], - "properties": { - "mon": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "tue": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "wed": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "thu": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "fri": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "sat": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "sun": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - } - } - } - ] - }, - "exceptions": { - "type": "object", - "maxProperties": 366, - "additionalProperties": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "description": "YYYY-MM-DD keys; each value replaces that date's weekly hours." - } - } - } - ], - "description": "Empty object means always open. Empty weekly hours mean always open except dated overrides. Empty interval lists mean closed. Sorted intervals use local wall time; split overnight hours across days." + "money_at": { + "type": "string", + "format": "date-time" + }, + "amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "currency": { + "type": "string", + "maxLength": 3 + } + }, + "required": [ + "amount", + "currency", + "id", + "issued_at", + "kind", + "money_at", + "number", + "payment" + ] + }, + "PaymentDocumentKindEnum": { + "enum": [ + "receipt", + "credit_note" + ], + "type": "string", + "description": "* `receipt` - Receipt\n* `credit_note` - Credit note" + }, + "PaymentFilterError": { + "oneOf": [ + { + "$ref": "#/components/schemas/HardwareRequestError" + }, + { + "$ref": "#/components/schemas/PaymentFilterValidationError" + } + ] + }, + "PaymentFilterValidationError": { + "type": "object", + "properties": { + "due_state": { + "type": "array", + "items": { + "type": "string" + } }, - "default_currency": { - "type": "string", - "maxLength": 3 + "status": { + "type": "array", + "items": { + "type": "string" + } }, - "timezone_choices": { + "subject_type": { "type": "array", "items": { "type": "string" - }, - "readOnly": true + } }, - "country_choices": { + "for_recording": { "type": "array", "items": { "type": "string" - }, - "readOnly": true + } } } }, - "PatchedMaterial": { + "PaymentListResponse": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "machine_type": { + "count": { "type": "integer" }, - "name": { + "next": { "type": "string", - "maxLength": 100 + "format": "uri", + "nullable": true }, - "colours": {}, - "is_active": { - "type": "boolean" + "previous": { + "type": "string", + "format": "uri", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PaymentReconciliation" + } } - } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] }, - "PatchedMemberCardOwnUpdate": { + "PaymentOffline": { "type": "object", "properties": { - "printed_name": { - "type": "string", - "maxLength": 200 + "settlement": { + "$ref": "#/components/schemas/ManualSettlement" } - } + }, + "required": [ + "settlement" + ] }, - "PatchedMembershipCapabilities": { + "PaymentOfflineRefund": { "type": "object", "properties": { - "can_refer": { - "type": "boolean" + "amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "can_verify": { - "type": "boolean" + "reason": { + "type": "string", + "default": "", + "maxLength": 255 + }, + "receipt": { + "$ref": "#/components/schemas/ManualRefundReceipt" } - } + }, + "required": [ + "amount", + "receipt" + ] }, - "PatchedMembershipPlan": { + "PaymentReconciliation": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "name": { + "subject_type": { + "allOf": [ + { + "$ref": "#/components/schemas/SubjectTypeEnum" + } + ], + "readOnly": true + }, + "subject_id": { + "type": "integer", + "readOnly": true + }, + "subject_label": { "type": "string", - "maxLength": 120 + "readOnly": true }, - "interval": { - "$ref": "#/components/schemas/IntervalEnum" + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/Status66aEnum" + } + ], + "readOnly": true }, - "custom_days": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0, + "due_at": { + "type": "string", + "format": "date-time", + "readOnly": true, "nullable": true }, + "due_date": { + "type": "string", + "nullable": true, + "readOnly": true + }, + "due_state": { + "type": "string", + "readOnly": true + }, "amount": { "type": "string", "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true }, "currency": { "type": "string", - "maxLength": 3 + "readOnly": true }, - "is_active": { - "type": "boolean" + "refunded_amount": { + "type": "number", + "format": "double", + "readOnly": true + }, + "refunds": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Refund" + }, + "readOnly": true + }, + "settlement": { + "allOf": [ + { + "$ref": "#/components/schemas/StaffSettlement" + } + ], + "nullable": true, + "readOnly": true }, "created_at": { "type": "string", @@ -92959,111 +115051,465 @@ "type": "string", "format": "date-time", "readOnly": true + } + }, + "required": [ + "amount", + "created_at", + "currency", + "due_at", + "due_date", + "due_state", + "id", + "refunded_amount", + "refunds", + "settlement", + "status", + "subject_id", + "subject_label", + "subject_type", + "updated_at" + ] + }, + "PaymentReconciliationReport": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "loan_max_active": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0, - "nullable": true + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PaymentReconciliationReportRow" + } + } + }, + "required": [ + "rows", + "typed_rows" + ] + }, + "PaymentReconciliationReportRow": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer" }, - "loan_max_items": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0, - "nullable": true + "currency": { + "type": "string" }, - "auto_renew": { - "type": "boolean" + "subject_type": { + "type": "string" }, - "renewal_open_days": { - "type": "integer", - "maximum": 365, - "minimum": 0 + "status": { + "type": "string" }, - "renewal_grace_days": { - "type": "integer", - "maximum": 365, - "minimum": 0 + "payment_count": { + "type": "integer" }, - "renewal_retries": { - "type": "integer", - "maximum": 3, - "minimum": 0 + "amount_total": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" }, - "reminder_days_before": {}, - "reminder_days_after": {} - } + "outstanding_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + }, + "settlement_method": { + "type": "string" + }, + "refunded_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + } + }, + "required": [ + "amount_total", + "currency", + "outstanding_amount", + "payment_count", + "refunded_amount", + "settlement_method", + "status", + "subject_type" + ] + }, + "PaymentRefundAmend": { + "type": "object", + "properties": { + "receipt": { + "$ref": "#/components/schemas/ManualRefundReceipt" + } + }, + "required": [ + "receipt" + ] + }, + "PaymentRefundRequest": { + "type": "object", + "properties": { + "amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "reason": { + "type": "string", + "default": "", + "maxLength": 255 + } + }, + "required": [ + "amount" + ] + }, + "PaymentSettingsError": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + }, + "required": [ + "detail" + ] + }, + "PaymentTimingEnum": { + "enum": [ + "after_activation", + "before_activation" + ], + "type": "string", + "description": "* `after_activation` - Activate then charge\n* `before_activation` - Pay before activation" + }, + "PaymentsAgeingReport": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PaymentAgeingRow" + } + } + }, + "required": [ + "rows", + "typed_rows" + ] + }, + "PaymentsLedgerReport": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LedgerLine" + } + } + }, + "required": [ + "rows", + "typed_rows" + ] + }, + "PendingClosure": { + "type": "object", + "properties": { + "digest": { + "type": "string", + "maxLength": 64 + }, + "identities": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ClosureIdentity" + } + } + }, + "required": [ + "digest", + "identities" + ] + }, + "Permissions43eEnum": { + "enum": [ + "edit_details", + "manage_reservations", + "check_in", + "publish_page" + ], + "type": "string", + "description": "* `edit_details` - edit_details\n* `manage_reservations` - manage_reservations\n* `check_in` - check_in\n* `publish_page` - publish_page" + }, + "Permissions5d1Enum": { + "enum": [ + "edit_details", + "manage_registrations", + "manage_invitations", + "check_in", + "publish_schedule" + ], + "type": "string", + "description": "* `edit_details` - edit_details\n* `manage_registrations` - manage_registrations\n* `manage_invitations` - manage_invitations\n* `check_in` - check_in\n* `publish_schedule` - publish_schedule" + }, + "PhaseEnum": { + "enum": [ + "issue", + "return" + ], + "type": "string", + "description": "* `issue` - issue\n* `return` - return" + }, + "PhoneConfirm": { + "type": "object", + "properties": { + "phone": { + "type": "string", + "maxLength": 32 + }, + "code": { + "type": "string", + "maxLength": 6, + "minLength": 6 + } + }, + "required": [ + "code", + "phone" + ] + }, + "PhoneLoginConfirm": { + "type": "object", + "properties": { + "phone": { + "type": "string", + "maxLength": 32 + }, + "delivery": { + "allOf": [ + { + "$ref": "#/components/schemas/DeliveryEnum" + } + ], + "default": "web" + }, + "client_platform": { + "allOf": [ + { + "$ref": "#/components/schemas/ClientPlatformEnum" + } + ], + "default": "web" + }, + "challenge": { + "type": "string", + "writeOnly": true, + "maxLength": 512 + }, + "code": { + "type": "string", + "maxLength": 6, + "minLength": 6 + }, + "attestation": { + "allOf": [ + { + "$ref": "#/components/schemas/DeviceAttestationPayload" + } + ], + "writeOnly": true + } + }, + "required": [ + "code", + "phone" + ] + }, + "PhoneLoginResult": { + "oneOf": [ + { + "$ref": "#/components/schemas/MFACompleted" + }, + { + "$ref": "#/components/schemas/MFAChallengeResponse" + }, + { + "$ref": "#/components/schemas/DeviceTokenResponse" + } + ] + }, + "PhoneLoginStart": { + "type": "object", + "properties": { + "phone": { + "type": "string", + "maxLength": 32 + }, + "delivery": { + "allOf": [ + { + "$ref": "#/components/schemas/DeliveryEnum" + } + ], + "default": "web" + }, + "client_platform": { + "allOf": [ + { + "$ref": "#/components/schemas/ClientPlatformEnum" + } + ], + "default": "web" + }, + "challenge": { + "type": "string", + "writeOnly": true, + "maxLength": 512 + } + }, + "required": [ + "phone" + ] + }, + "PhoneStart": { + "type": "object", + "properties": { + "phone": { + "type": "string", + "maxLength": 32 + } + }, + "required": [ + "phone" + ] }, - "PatchedMembershipRoleAssign": { + "PhoneStartResponse": { "type": "object", "properties": { - "role_id": { - "type": "integer" + "detail": { + "type": "string" } - } + }, + "required": [ + "detail" + ] }, - "PatchedNotificationRecipientsPatch": { + "PhoneStatus": { "type": "object", "properties": { - "recipients": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationRecipientUpdate" - } + "phone_e164": { + "type": "string" + }, + "verified": { + "type": "boolean" } - } + }, + "required": [ + "phone_e164", + "verified" + ] }, - "PatchedNotificationRulesPatch": { + "PlatformAnalyticsReport": { "type": "object", "properties": { - "changes": { + "report_key": { + "type": "string" + }, + "rows": { "type": "array", "items": { - "$ref": "#/components/schemas/NotificationRuleChange" + "type": "array", + "items": {} } }, - "preferences": { + "typed_rows": { "type": "array", "items": { - "$ref": "#/components/schemas/NotificationPreferenceChange" + "type": "object", + "additionalProperties": {} } + }, + "meta": { + "type": "object", + "additionalProperties": {} + }, + "chart_spec": { + "allOf": [ + { + "$ref": "#/components/schemas/ReportChartSpec" + } + ], + "description": "Canonical chart fields, units, and partitions from the report definition." } - } + }, + "required": [ + "chart_spec", + "rows" + ] }, - "PatchedOrganizationProfileUpdate": { + "PlatformBackupHealth": { "type": "object", "properties": { - "name": { - "type": "string", - "maxLength": 200 + "automatic_enabled": { + "type": "boolean" }, - "slug": { + "last_success_at": { "type": "string", - "maxLength": 50, - "pattern": "^[-a-zA-Z0-9_]+$" + "format": "date-time", + "nullable": true }, - "description": { + "status": { "type": "string" }, - "website": { - "oneOf": [ - { - "type": "string", - "format": "uri", - "maxLength": 200 - }, - { - "type": "string", - "maxLength": 0 - } - ] + "started_at": { + "type": "string", + "format": "date-time", + "nullable": true }, - "public_profile_enabled": { + "finished_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "has_error": { "type": "boolean" + }, + "cadence_minutes": { + "type": "integer" } - } + }, + "required": [ + "automatic_enabled", + "cadence_minutes", + "finished_at", + "has_error", + "last_success_at", + "started_at", + "status" + ] }, - "PatchedPlatformBackupSettings": { + "PlatformBackupSettings": { "type": "object", "properties": { "automatic_backups_enabled": { @@ -93095,9 +115541,70 @@ "format": "date-time", "readOnly": true } - } + }, + "required": [ + "last_error", + "last_scheduled_at", + "last_success_at", + "updated_at" + ] }, - "PatchedPlatformEmailSettings": { + "PlatformDashboard": { + "type": "object", + "properties": { + "checked_at": { + "type": "string", + "format": "date-time" + }, + "tenants_by_state": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "usage_totals": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "tenants": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlatformTenantUsage" + } + }, + "backup": { + "$ref": "#/components/schemas/PlatformBackupHealth" + }, + "offsite": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlatformOffsiteHealth" + } + }, + "scheduler": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlatformTaskHealth" + } + }, + "failed_deliveries": { + "$ref": "#/components/schemas/FailedDeliveries" + } + }, + "required": [ + "backup", + "checked_at", + "failed_deliveries", + "offsite", + "scheduler", + "tenants", + "tenants_by_state", + "usage_totals" + ] + }, + "PlatformEmailSettings": { "type": "object", "properties": { "id": { @@ -93149,9 +115656,240 @@ "format": "date-time", "readOnly": true } - } + }, + "required": [ + "id", + "smtp_password_set", + "updated_at" + ] }, - "PatchedPlatformSocialAuthSettings": { + "PlatformEnum": { + "enum": [ + "apple", + "android" + ], + "type": "string", + "description": "* `apple` - Apple\n* `android` - Android" + }, + "PlatformOffsiteHealth": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "state": { + "type": "string" + }, + "verified_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "has_error": { + "type": "boolean" + } + }, + "required": [ + "has_error", + "name", + "state", + "verified_at" + ] + }, + "PlatformProblem": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/Status00eEnum" + }, + "message": { + "type": "string" + }, + "fix_url": { + "type": "string" + }, + "makerspace_id": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "fix_url", + "key", + "makerspace_id", + "message", + "status" + ] + }, + "PlatformProblems": { + "type": "object", + "properties": { + "checked_at": { + "type": "string", + "format": "date-time" + }, + "status": { + "$ref": "#/components/schemas/Status00eEnum" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlatformProblem" + } + } + }, + "required": [ + "checked_at", + "results", + "status" + ] + }, + "PlatformReadiness": { + "type": "object", + "properties": { + "hint": { + "type": "string" + }, + "login_ready": { + "type": "boolean" + }, + "push_ready": { + "type": "boolean" + }, + "apple_revocation_ready": { + "type": "boolean" + }, + "associations_ready": { + "type": "boolean" + }, + "registrations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RegistrationReadiness" + } + } + }, + "required": [ + "apple_revocation_ready", + "associations_ready", + "hint", + "login_ready", + "push_ready", + "registrations" + ] + }, + "PlatformServicesReport": { + "type": "object", + "properties": { + "report_key": { + "type": "string" + }, + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlatformServicesRow" + } + }, + "meta": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "rows", + "typed_rows" + ] + }, + "PlatformServicesRow": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer", + "nullable": true + }, + "period": { + "type": "string", + "format": "date", + "nullable": true + }, + "day": { + "type": "string", + "format": "date" + }, + "service": { + "type": "string" + }, + "provider_source": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "nullable": true + }, + "bucket": { + "type": "string" + }, + "count": { + "type": "integer", + "minimum": 0 + }, + "currency": { + "type": "string", + "nullable": true + }, + "amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$", + "nullable": true + }, + "purpose": { + "nullable": true, + "description": "SMS OTP vs addressed notifications. Null for other services and hidden buckets.\n\n* `otp` - otp\n* `notifications` - notifications", + "oneOf": [ + { + "$ref": "#/components/schemas/PlatformServicesRowPurposeEnum" + }, + { + "$ref": "#/components/schemas/NullEnum" + } + ] + } + }, + "required": [ + "amount", + "bucket", + "count", + "currency", + "day", + "makerspace_id", + "period", + "provider_source", + "purpose", + "service", + "status" + ] + }, + "PlatformServicesRowPurposeEnum": { + "enum": [ + "otp", + "notifications" + ], + "type": "string", + "description": "* `otp` - otp\n* `notifications` - notifications" + }, + "PlatformSocialAuthSettings": { "type": "object", "properties": { "google_enabled": { @@ -93221,9 +115959,17 @@ "format": "date-time", "readOnly": true } - } + }, + "required": [ + "apple_enabled", + "apple_private_key_set", + "github_client_secret_set", + "github_enabled", + "google_enabled", + "updated_at" + ] }, - "PatchedPlatformStripeConnectSettings": { + "PlatformStripeConnectSettings": { "type": "object", "properties": { "id": { @@ -93255,2340 +116001,2747 @@ "type": "boolean", "readOnly": true }, - "stripe_connect_client_id": { - "type": "string", - "maxLength": 255 + "stripe_connect_client_id": { + "type": "string", + "maxLength": 255 + }, + "application_fee_bps": { + "type": "integer", + "maximum": 10000, + "minimum": 0 + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "id", + "stripe_publishable_key_set", + "stripe_secret_key_set", + "stripe_webhook_secret_set", + "updated_at" + ] + }, + "PlatformTaskHealth": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "task": { + "type": "string" + }, + "cadence_minutes": { + "type": "integer" + }, + "last_run_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "status": { + "$ref": "#/components/schemas/Status00eEnum" + } + }, + "required": [ + "cadence_minutes", + "last_run_at", + "name", + "status", + "task" + ] + }, + "PlatformTenantUsage": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "usage": { + "$ref": "#/components/schemas/MakerspaceUsage" + } + }, + "required": [ + "id", + "name", + "slug", + "usage" + ] + }, + "Policy": { + "type": "object", + "properties": { + "blind_default": { + "type": "boolean" + }, + "variance_threshold": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0, + "nullable": true + } + } + }, + "PositionedElement": { + "type": "object", + "properties": { + "field": { + "type": "string", + "maxLength": 80 + }, + "x_mm": { + "type": "number", + "format": "double", + "minimum": 0 + }, + "y_mm": { + "type": "number", + "format": "double", + "minimum": 0 + }, + "width_mm": { + "type": "number", + "format": "double", + "minimum": 0.5 + }, + "height_mm": { + "type": "number", + "format": "double", + "minimum": 0.5 + }, + "font": { + "$ref": "#/components/schemas/FontEnum" + }, + "font_size_pt": { + "type": "number", + "format": "double", + "maximum": 48, + "minimum": 6 + }, + "bold": { + "type": "boolean" }, - "application_fee_bps": { - "type": "integer", - "maximum": 10000, - "minimum": 0 + "align": { + "$ref": "#/components/schemas/AlignEnum" }, - "updated_at": { + "colour": { "type": "string", - "format": "date-time", - "readOnly": true + "pattern": "^#[0-9A-Fa-f]{6}$" } - } + }, + "required": [ + "field", + "height_mm", + "width_mm", + "x_mm", + "y_mm" + ] }, - "PatchedPreferenceWrite": { + "PreferenceCell": { "type": "object", "properties": { "feature": { "$ref": "#/components/schemas/Feature9d8Enum" }, "channel": { - "$ref": "#/components/schemas/Channel673Enum" + "$ref": "#/components/schemas/ChannelB6fEnum" }, "enabled": { "type": "boolean" }, "daily_digest": { "type": "boolean" + }, + "available": { + "type": "boolean" + }, + "mandatory_events": { + "type": "array", + "items": { + "type": "string" + } } - } + }, + "required": [ + "available", + "channel", + "enabled", + "feature", + "mandatory_events" + ] }, - "PatchedPrinterPoolVisibility": { + "PreferenceResponse": { "type": "object", "properties": { - "is_public": { + "cells": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PreferenceCell" + } + } + }, + "required": [ + "cells" + ] + }, + "PresenceCurrent": { + "type": "object", + "properties": { + "active": { "type": "boolean" + }, + "session": { + "allOf": [ + { + "$ref": "#/components/schemas/PresenceSession" + } + ], + "nullable": true } - } + }, + "required": [ + "active", + "session" + ] }, - "PatchedReportSchedule": { + "PresenceRoster": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true + "display_name": { + "type": "string" }, - "makerspace": { - "type": "integer", + "role_label": { + "type": "string", "readOnly": true }, - "report_key": { - "$ref": "#/components/schemas/ReportKeyEnum" - }, - "filters": { - "$ref": "#/components/schemas/ReportScheduleFilters" - }, - "grain": { + "started_at": { "type": "string", - "maxLength": 8 - }, - "format": { - "$ref": "#/components/schemas/ReportScheduleFormatEnum" - }, - "cadence": { - "$ref": "#/components/schemas/CadenceEnum" + "format": "date-time" }, - "next_run_at": { + "expires_at": { "type": "string", "format": "date-time" - }, - "last_run_at": { + } + }, + "required": [ + "display_name", + "expires_at", + "role_label", + "started_at" + ] + }, + "PresenceSession": { + "type": "object", + "properties": { + "started_at": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "is_active": { - "type": "boolean" - }, - "destination": { - "type": "integer", - "nullable": true - }, - "recipient_emails": { - "type": "array", - "items": { - "type": "string", - "format": "email" - }, - "maxItems": 10 - }, - "created_by": { - "type": "integer", - "readOnly": true, - "nullable": true + "format": "date-time" }, - "created_at": { + "expires_at": { "type": "string", - "format": "date-time", - "readOnly": true + "format": "date-time" }, - "updated_at": { + "ended_at": { "type": "string", "format": "date-time", - "readOnly": true + "nullable": true }, - "last_delivery": { - "allOf": [ + "end_reason": { + "oneOf": [ { - "$ref": "#/components/schemas/ReportDelivery" + "$ref": "#/components/schemas/EndReasonEnum" + }, + { + "$ref": "#/components/schemas/BlankEnum" } - ], - "nullable": true, - "readOnly": true - }, - "condition": { - "$ref": "#/components/schemas/ReportCondition" - }, - "recipient_roles": { - "type": "array", - "items": { - "type": "string", - "maxLength": 80, - "pattern": "^[-a-zA-Z0-9_]+$" - }, - "maxItems": 20 + ] }, - "recipient_actions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RecipientActionsEnum" - }, - "maxItems": 20 + "active": { + "type": "boolean", + "readOnly": true } - } + }, + "required": [ + "active", + "expires_at", + "started_at" + ] }, - "PatchedReturnPolicy": { + "PresenceStart": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "default_loan_days": { - "type": "integer", - "maximum": 365, - "minimum": 1 - }, - "loan_renewal_mode": { - "$ref": "#/components/schemas/LoanRenewalModeEnum" - }, - "loan_max_renewals": { - "type": "integer", - "maximum": 100, - "minimum": 0 - }, - "loan_renewal_days": { - "type": "integer", - "maximum": 365, - "minimum": 1 - }, - "loan_max_active": { + "duration_minutes": { "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "maximum": 480, + "minimum": 15 }, - "loan_max_items": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "latitude": { + "type": "number", + "format": "double", + "maximum": 90, + "minimum": -90 }, - "loan_reminder_offsets_hours": { - "type": "array", - "items": { - "type": "integer", - "minimum": -8760, - "maximum": 8760 - }, - "maxItems": 100, - "uniqueItems": true, - "description": "Signed whole hours from due time: negative before, zero due, positive overdue." + "longitude": { + "type": "number", + "format": "double", + "maximum": 180, + "minimum": -180 }, - "loan_escalate_overdue_days": { - "type": "integer", - "maximum": 365, + "accuracy": { + "type": "number", + "format": "double", "minimum": 0 } - } + }, + "required": [ + "duration_minutes" + ] }, - "PatchedRoleId": { - "type": "object", - "properties": { - "role_id": { - "type": "integer" - } - } + "PresetEnum": { + "enum": [ + "sheet", + "single" + ], + "type": "string", + "description": "* `sheet` - sheet\n* `single` - single" }, - "PatchedRoleWrite": { + "PrinterJobsReport": { "type": "object", "properties": { - "name": { - "type": "string", - "maxLength": 80 + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "granted_actions": { + "typed_rows": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/PrinterJobsRow" } } - } + }, + "required": [ + "rows", + "typed_rows" + ] }, - "PatchedSavedReportView": { + "PrinterJobsRow": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true + "makerspace_id": { + "type": "integer" }, - "makerspace": { - "type": "integer", - "readOnly": true + "period": { + "type": "string", + "format": "date", + "nullable": true }, - "owner": { + "machine_id": { "type": "integer", - "readOnly": true + "nullable": true }, - "name": { + "machine_name": { "type": "string", - "maxLength": 80 + "nullable": true }, - "state": { - "$ref": "#/components/schemas/ReportViewState" + "machine_type": { + "type": "string", + "nullable": true }, - "shared": { - "type": "boolean" + "state": { + "type": "string" }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "outcome": { + "type": "string" }, - "updated_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "job_count": { + "type": "integer" } - } + }, + "required": [ + "job_count", + "machine_id", + "machine_name", + "machine_type", + "outcome", + "period", + "state" + ] }, - "PatchedSavedView": { + "PrinterPool": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "makerspace": { + "machine_id": { "type": "integer", - "readOnly": true + "readOnly": true, + "nullable": true }, - "owner": { + "machine_type_id": { "type": "integer", - "readOnly": true + "readOnly": true, + "nullable": true }, - "owner_username": { + "material": { "type": "string", - "readOnly": true + "maxLength": 100 }, - "name": { + "color": { "type": "string", - "maxLength": 80 - }, - "entity": { - "$ref": "#/components/schemas/SavedViewEntityEnum" - }, - "state": { - "type": "object", - "additionalProperties": { - "type": "string", - "maxLength": 250 - } - }, - "shared": { - "type": "boolean" + "maxLength": 100 }, - "created_at": { + "color_hex": { "type": "string", - "format": "date-time", - "readOnly": true + "maxLength": 7 }, - "updated_at": { + "brand": { "type": "string", - "format": "date-time", - "readOnly": true - } - } - }, - "PatchedSchedule": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "readOnly": true + "maxLength": 100 }, - "name": { + "lot_code": { "type": "string", - "maxLength": 200 - }, - "container": { - "type": "integer", - "nullable": true + "maxLength": 100 }, - "interval_days": { - "type": "integer", - "minimum": 1 + "unit": { + "$ref": "#/components/schemas/PrinterPoolUnitEnum" }, - "next_due": { + "unit_cost": { "type": "string", - "format": "date" + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,4})?$", + "nullable": true }, - "blind": { - "type": "boolean" + "cost_currency": { + "type": "string", + "maxLength": 3 }, - "is_active": { - "type": "boolean" - } - } - }, - "PatchedTimingPolicy": { - "type": "object", - "properties": { - "quiet_start": { + "cost_unit": { "type": "string", - "format": "time" + "maxLength": 16 }, - "quiet_end": { + "initial_grams": { "type": "string", - "format": "time" + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "channels": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ChannelsEnum" - }, - "maxItems": 7 + "remaining_grams": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true }, - "digest_at": { + "low_threshold_grams": { "type": "string", - "format": "time" + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "nullable": true }, - "daily_digest_default": { + "is_active": { + "type": "boolean", + "readOnly": true + }, + "is_public": { "type": "boolean" }, - "bounce_secret": { - "writeOnly": true, - "oneOf": [ - { - "type": "string", - "maxLength": 200, - "minLength": 32 - }, - { - "type": "string", - "maxLength": 0 - } - ] + "opened_at": { + "type": "string", + "format": "date-time", + "nullable": true }, - "bounce_configured": { - "type": "boolean", + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", "readOnly": true } - } + }, + "required": [ + "created_at", + "id", + "initial_grams", + "is_active", + "machine_id", + "machine_type_id", + "material", + "remaining_grams", + "updated_at" + ] }, - "PatchedToBuyItem": { + "PrinterPoolCorrection": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "makerspace": { - "type": "integer", - "readOnly": true - }, - "kind": { - "allOf": [ - { - "$ref": "#/components/schemas/ToBuyItemKindEnum" - } - ], - "readOnly": true + "quantity_delta": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "machine_type": { - "type": "integer", + "reason": { + "type": "string" + } + }, + "required": [ + "quantity_delta", + "reason" + ] + }, + "PrinterPoolCreate": { + "type": "object", + "properties": { + "unit_cost": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,4})?$", "nullable": true }, - "machine_type_name": { + "cost_currency": { "type": "string", - "readOnly": true, - "nullable": true + "maxLength": 3 }, - "name": { + "cost_unit": { "type": "string", - "maxLength": 200 + "maxLength": 16 }, - "quantity": { + "machine_id": { "type": "integer", "minimum": 1, - "default": 1 + "nullable": true }, - "link": { - "oneOf": [ - { - "type": "string", - "format": "uri", - "maxLength": 200 - }, - { - "type": "string", - "maxLength": 0 - } - ] + "machine_type_id": { + "type": "integer", + "minimum": 1, + "nullable": true }, - "status": { - "$ref": "#/components/schemas/ToBuyItemStatusEnum" + "is_public": { + "type": "boolean", + "default": true }, - "estimated_unit_cost": { + "material": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$", - "nullable": true + "maxLength": 100 }, - "vendor_name": { + "color": { "type": "string", - "maxLength": 200 + "maxLength": 100 }, - "actual_unit_cost": { + "color_hex": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$", - "nullable": true - }, - "purchaser": { - "type": "integer", - "readOnly": true + "maxLength": 7 }, - "purchaser_username": { + "brand": { "type": "string", - "readOnly": true + "maxLength": 100 }, - "ordered_at": { + "lot_code": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "maxLength": 100 }, - "received_at": { + "unit": { + "allOf": [ + { + "$ref": "#/components/schemas/PrinterPoolCreateUnitEnum" + } + ], + "default": "grams" + }, + "quantity": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "moved_to_inventory_at": { + "initial_grams": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "resulting_product": { - "type": "integer", - "readOnly": true, + "low_threshold_grams": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", "nullable": true + } + }, + "required": [ + "material" + ] + }, + "PrinterPoolCreateUnitEnum": { + "enum": [ + "grams", + "milliliters", + "millimeters", + "count" + ], + "type": "string", + "description": "* `grams` - grams\n* `milliliters` - milliliters\n* `millimeters` - millimeters\n* `count` - count" + }, + "PrinterPoolUnitEnum": { + "enum": [ + "grams", + "milliliters", + "millimeters", + "count" + ], + "type": "string", + "description": "* `grams` - Grams\n* `milliliters` - Milliliters\n* `millimeters` - Millimeters\n* `count` - Count" + }, + "PrinterServiceReport": { + "type": "object", + "properties": { + "printer_metrics": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PrinterServiceReportRow" + } + } + }, + "required": [ + "printer_metrics" + ] + }, + "PrinterServiceReportRow": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer" }, - "resulting_pool": { - "type": "integer", - "readOnly": true, - "nullable": true + "machine_id": { + "type": "integer" }, - "resulting_machine": { - "type": "integer", - "readOnly": true, - "nullable": true + "machine_name": { + "type": "string" }, - "source_pool": { - "type": "integer", - "readOnly": true, - "nullable": true + "model": { + "type": "string" }, - "source_product": { - "type": "integer", - "readOnly": true, - "nullable": true + "completed_hours": { + "type": "number", + "format": "double" }, - "receipts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ToBuyReceipt" - }, - "readOnly": true + "failed_partial_hours": { + "type": "number", + "format": "double" }, - "created_by": { - "type": "integer", - "readOnly": true, - "nullable": true + "manual_hours": { + "type": "number", + "format": "double" }, - "created_by_username": { + "consumed_grams": { "type": "string", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "created_at": { + "payment_due": { "type": "string", - "format": "date-time", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "updated_at": { + "payment_paid": { "type": "string", - "format": "date-time", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" } - } + }, + "required": [ + "completed_hours", + "consumed_grams", + "failed_partial_hours", + "machine_id", + "machine_name", + "manual_hours", + "model", + "payment_due", + "payment_paid" + ] }, - "PaymentBulkAction": { + "ProblemReportTriage": { "type": "object", "properties": { - "ids": { + "outcome": { + "$ref": "#/components/schemas/ProblemReportTriageOutcomeEnum" + }, + "resolutions": { "type": "array", "items": { - "type": "integer", - "minimum": 1 + "$ref": "#/components/schemas/ProblemReportTriageResolution" } + }, + "note": { + "type": "string", + "default": "" + }, + "evidence_id": { + "type": "integer", + "nullable": true } }, "required": [ - "ids" + "outcome" ] }, - "PaymentBulkOffline": { + "ProblemReportTriageOutcomeEnum": { + "enum": [ + "no_issue", + "damaged", + "missing", + "needs_fix" + ], + "type": "string", + "description": "* `no_issue` - no_issue\n* `damaged` - damaged\n* `missing` - missing\n* `needs_fix` - needs_fix" + }, + "ProblemReportTriageResolution": { "type": "object", "properties": { - "ids": { - "type": "array", - "items": { - "type": "integer", - "minimum": 1 - } + "item_id": { + "type": "integer" }, - "settlement": { - "$ref": "#/components/schemas/ManualSettlement" + "quantity": { + "type": "integer", + "minimum": 1 } }, "required": [ - "ids", - "settlement" + "item_id", + "quantity" ] }, - "PaymentCashBook": { + "ProblemReportTriageResponse": { "type": "object", "properties": { - "rows": { + "id": { + "type": "integer" + }, + "outcome": { + "type": "string" + }, + "resolved": { + "type": "boolean" + } + }, + "required": [ + "id", + "outcome", + "resolved" + ] + }, + "ProductQrHistory": { + "type": "object", + "properties": { + "product": { + "type": "integer" + }, + "scans": { "type": "array", "items": { - "type": "array", - "items": {} + "$ref": "#/components/schemas/QrHistoryItem" } + } + }, + "required": [ + "product", + "scans" + ] + }, + "ProfileActivity": { + "type": "object", + "properties": { + "events_attended": { + "type": "integer" }, - "typed_rows": { + "events_registered": { + "type": "integer" + }, + "recent_attended_events": { "type": "array", "items": { - "$ref": "#/components/schemas/PaymentCashBookRow" + "$ref": "#/components/schemas/AttendedEvent" + } + } + } + }, + "ProfileError": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "fields": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } } } }, "required": [ - "rows", - "typed_rows" + "code", + "detail", + "fields" ] }, - "PaymentCashBookRow": { + "ProfileGateError": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" - }, - "currency": { + "code": { "type": "string" }, - "method": { + "detail": { "type": "string" }, - "collected_amount": { + "purpose": { + "type": "string" + } + }, + "required": [ + "code", + "detail" + ] + }, + "ProfileImageAttachRequest": { + "type": "object", + "properties": { + "object_key": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + "maxLength": 300 }, - "refunded_amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + "project_id": { + "type": "integer" + } + }, + "required": [ + "object_key" + ] + }, + "ProfileImageUploadRequest": { + "type": "object", + "properties": { + "content_type": { + "type": "string" }, - "net_amount": { + "filename": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + "maxLength": 255 + }, + "project_id": { + "type": "integer" } }, "required": [ - "collected_amount", - "currency", - "method", - "net_amount", - "refunded_amount" + "content_type", + "filename" ] }, - "PaymentDocument": { + "ProfileRead": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "payment": { + "membership_id": { "type": "integer" }, - "number": { - "type": "string", - "maxLength": 80 + "display_name": { + "type": "string" }, - "kind": { - "$ref": "#/components/schemas/PaymentDocumentKindEnum" + "is_visible": { + "type": "boolean" }, - "issued_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "show_attended_events": { + "type": "boolean" }, - "money_at": { - "type": "string", - "format": "date-time" + "show_certifications": { + "type": "boolean" }, - "amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "certifications": { + "type": "array", + "items": { + "type": "string" + } }, - "currency": { + "headline": { + "type": "string" + }, + "institution": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "avatar_url": { "type": "string", - "maxLength": 3 + "nullable": true + }, + "interests": {}, + "languages": {}, + "education": {}, + "github_username": { + "type": "string" + }, + "github_contributions": { + "type": "integer", + "nullable": true + }, + "projects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectRead" + } + }, + "activity": { + "$ref": "#/components/schemas/ProfileActivity" } }, "required": [ - "amount", - "currency", - "id", - "issued_at", - "kind", - "money_at", - "number", - "payment" - ] - }, - "PaymentDocumentKindEnum": { - "enum": [ - "receipt", - "credit_note" - ], - "type": "string", - "description": "* `receipt` - Receipt\n* `credit_note` - Credit note" - }, - "PaymentFilterError": { - "oneOf": [ - { - "$ref": "#/components/schemas/HardwareRequestError" - }, - { - "$ref": "#/components/schemas/PaymentFilterValidationError" - } + "activity", + "avatar_url", + "bio", + "certifications", + "display_name", + "education", + "github_contributions", + "github_username", + "headline", + "institution", + "interests", + "is_visible", + "languages", + "membership_id", + "projects", + "show_attended_events", + "show_certifications" ] }, - "PaymentFilterValidationError": { + "ProfileWrite": { "type": "object", "properties": { - "due_state": { + "is_visible": { + "type": "boolean" + }, + "show_attended_events": { + "type": "boolean" + }, + "show_certifications": { + "type": "boolean" + }, + "headline": { + "type": "string", + "maxLength": 200 + }, + "institution": { + "type": "string", + "maxLength": 200 + }, + "bio": { + "type": "string", + "maxLength": 2000 + }, + "interests": { "type": "array", "items": { - "type": "string" - } + "type": "string", + "maxLength": 60 + }, + "maxItems": 20 }, - "status": { + "languages": { "type": "array", "items": { - "type": "string" - } + "type": "string", + "maxLength": 60 + }, + "maxItems": 10 }, - "subject_type": { + "education": { "type": "array", "items": { - "type": "string" - } + "$ref": "#/components/schemas/EducationEntry" + }, + "maxItems": 10 }, - "for_recording": { + "github_username": { + "oneOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$" + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "projects": { "type": "array", "items": { - "type": "string" - } + "$ref": "#/components/schemas/ProjectWrite" + }, + "maxItems": 20 } } }, - "PaymentListResponse": { + "ProjectLink": { "type": "object", "properties": { - "count": { - "type": "integer" - }, - "next": { + "label": { "type": "string", - "format": "uri", - "nullable": true + "maxLength": 60 }, - "previous": { + "url": { "type": "string", "format": "uri", - "nullable": true - }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PaymentReconciliation" - } + "maxLength": 500 } }, "required": [ - "count", - "next", - "previous", - "results" + "label", + "url" ] }, - "PaymentOffline": { + "ProjectRead": { "type": "object", "properties": { - "settlement": { - "$ref": "#/components/schemas/ManualSettlement" + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "links": {}, + "image_url": { + "type": "string", + "nullable": true } }, "required": [ - "settlement" + "description", + "id", + "image_url", + "links", + "title" ] }, - "PaymentOfflineRefund": { + "ProjectWrite": { "type": "object", "properties": { - "amount": { + "id": { + "type": "integer" + }, + "title": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "maxLength": 200 }, - "reason": { + "description": { "type": "string", "default": "", - "maxLength": 255 + "maxLength": 4000 }, - "receipt": { - "$ref": "#/components/schemas/ManualRefundReceipt" + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectLink" + }, + "maxItems": 10 } }, "required": [ - "amount", - "receipt" + "title" ] }, - "PaymentReconciliation": { + "Promo": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "subject_type": { - "allOf": [ - { - "$ref": "#/components/schemas/SubjectTypeEnum" - } - ], - "readOnly": true - }, - "subject_id": { + "percent_off": { "type": "integer", "readOnly": true }, - "subject_label": { + "amount_off": { "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", "readOnly": true }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/Status66aEnum" - } - ], + "max_uses": { + "type": "integer", "readOnly": true }, - "due_at": { + "uses": { + "type": "integer", + "readOnly": true + }, + "expires_at": { "type": "string", "format": "date-time", "readOnly": true, "nullable": true }, - "due_date": { - "type": "string", - "nullable": true, - "readOnly": true - }, - "due_state": { - "type": "string", - "readOnly": true - }, - "amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "readOnly": true - }, - "currency": { + "revoked_at": { "type": "string", - "readOnly": true - }, - "refunded_amount": { - "type": "number", - "format": "double", - "readOnly": true - }, - "refunds": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Refund" - }, - "readOnly": true - }, - "settlement": { - "allOf": [ - { - "$ref": "#/components/schemas/StaffSettlement" - } - ], - "nullable": true, - "readOnly": true + "format": "date-time", + "readOnly": true, + "nullable": true }, "created_at": { "type": "string", "format": "date-time", "readOnly": true }, - "updated_at": { + "code": { "type": "string", - "format": "date-time", - "readOnly": true + "readOnly": true, + "description": "Returned once on creation only." } }, "required": [ - "amount", + "amount_off", + "code", "created_at", - "currency", - "due_at", - "due_date", - "due_state", + "expires_at", "id", - "refunded_amount", - "refunds", - "settlement", - "status", - "subject_id", - "subject_label", - "subject_type", - "updated_at" - ] - }, - "PaymentReconciliationReport": { - "type": "object", - "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "typed_rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PaymentReconciliationReportRow" - } - } - }, - "required": [ - "rows", - "typed_rows" + "max_uses", + "percent_off", + "revoked_at", + "uses" ] }, - "PaymentReconciliationReportRow": { + "PromoInput": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" - }, - "currency": { - "type": "string" - }, - "subject_type": { - "type": "string" - }, - "status": { - "type": "string" - }, - "payment_count": { - "type": "integer" - }, - "amount_total": { + "code": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + "writeOnly": true, + "maxLength": 128, + "minLength": 8 }, - "outstanding_amount": { + "percent_off": { + "type": "integer", + "maximum": 100, + "minimum": 0, + "default": 0 + }, + "amount_off": { "type": "string", "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "default": "0.00" }, - "settlement_method": { - "type": "string" + "max_uses": { + "type": "integer", + "minimum": 1, + "default": 1 }, - "refunded_amount": { + "expires_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" - } - }, - "required": [ - "amount_total", - "currency", - "outstanding_amount", - "payment_count", - "refunded_amount", - "settlement_method", - "status", - "subject_type" - ] - }, - "PaymentRefundAmend": { - "type": "object", - "properties": { - "receipt": { - "$ref": "#/components/schemas/ManualRefundReceipt" + "format": "date-time", + "nullable": true } - }, - "required": [ - "receipt" - ] + } }, - "PaymentRefundRequest": { + "PromoList": { "type": "object", "properties": { - "amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "count": { + "type": "integer" }, - "reason": { + "next": { "type": "string", - "default": "", - "maxLength": 255 - } - }, - "required": [ - "amount" - ] - }, - "PaymentSettingsError": { - "type": "object", - "properties": { - "detail": { - "type": "string" - } - }, - "required": [ - "detail" - ] - }, - "PaymentTimingEnum": { - "enum": [ - "after_activation", - "before_activation" - ], - "type": "string", - "description": "* `after_activation` - Activate then charge\n* `before_activation` - Pay before activation" - }, - "PaymentsLedgerReport": { - "type": "object", - "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } + "format": "uri", + "nullable": true }, - "typed_rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LedgerLine" - } - } - }, - "required": [ - "rows", - "typed_rows" - ] - }, - "PendingClosure": { - "type": "object", - "properties": { - "digest": { + "previous": { "type": "string", - "maxLength": 64 + "format": "uri", + "nullable": true }, - "identities": { + "results": { "type": "array", "items": { - "$ref": "#/components/schemas/ClosureIdentity" + "$ref": "#/components/schemas/Promo" } } }, "required": [ - "digest", - "identities" + "count", + "next", + "previous", + "results" ] }, - "PhaseEnum": { - "enum": [ - "issue", - "return" - ], - "type": "string", - "description": "* `issue` - issue\n* `return` - return" - }, - "PhoneConfirm": { + "ProvisionSubdomainRequest": { "type": "object", "properties": { - "phone": { - "type": "string", - "maxLength": 32 - }, - "code": { + "label": { "type": "string", - "maxLength": 6, - "minLength": 6 + "maxLength": 63 } }, "required": [ - "code", - "phone" - ] - }, - "PhoneLoginResult": { - "oneOf": [ - { - "$ref": "#/components/schemas/MFACompleted" - }, - { - "$ref": "#/components/schemas/MFAChallengeResponse" - } + "label" ] }, - "PhoneStart": { + "ProvisionSubdomainValidationError": { "type": "object", "properties": { - "phone": { - "type": "string", - "maxLength": 32 + "label": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ - "phone" + "label" ] }, - "PhoneStartResponse": { + "PublicAdmission": { "type": "object", "properties": { - "detail": { + "join_mode": { "type": "string" - } - }, - "required": [ - "detail" - ] - }, - "PhoneStatus": { - "type": "object", - "properties": { - "phone_e164": { + }, + "vouches_required": { + "type": "integer" + }, + "application_schema": { + "nullable": true + }, + "default_plan": { + "type": "object", + "additionalProperties": {}, + "nullable": true + }, + "joining_fee": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "joining_fee_currency": { "type": "string" }, - "verified": { - "type": "boolean" + "payment_timing": { + "type": "string" } }, "required": [ - "phone_e164", - "verified" + "application_schema", + "default_plan", + "join_mode", + "joining_fee", + "joining_fee_currency", + "payment_timing", + "vouches_required" ] }, - "PlatformBackupHealth": { - "type": "object", - "properties": { - "automatic_enabled": { - "type": "boolean" - }, - "last_success_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "status": { - "type": "string" - }, - "started_at": { + "PublicAvailabilityInterval": { + "type": "object", + "properties": { + "starts_at": { "type": "string", "format": "date-time", - "nullable": true + "readOnly": true }, - "finished_at": { + "ends_at": { "type": "string", "format": "date-time", - "nullable": true - }, - "has_error": { - "type": "boolean" + "readOnly": true }, - "cadence_minutes": { - "type": "integer" + "booker_name": { + "type": "string", + "readOnly": true, + "nullable": true } }, "required": [ - "automatic_enabled", - "cadence_minutes", - "finished_at", - "has_error", - "last_success_at", - "started_at", - "status" + "booker_name", + "ends_at", + "starts_at" ] }, - "PlatformBackupSettings": { + "PublicAvailabilityModeFc2Enum": { + "enum": [ + "exact_count", + "status_only", + "hidden" + ], + "type": "string", + "description": "* `exact_count` - Exact Count\n* `status_only` - Status Only\n* `hidden` - Hidden" + }, + "PublicBookableSpace": { "type": "object", "properties": { - "automatic_backups_enabled": { - "type": "boolean" + "public_token": { + "type": "string", + "format": "uuid", + "readOnly": true }, - "retention_days": { + "name": { + "type": "string", + "readOnly": true + }, + "kind": { + "allOf": [ + { + "$ref": "#/components/schemas/Kind3bfEnum" + } + ], + "readOnly": true + }, + "description": { + "type": "string", + "readOnly": true + }, + "capacity": { "type": "integer", - "maximum": 3650, - "minimum": 1 + "minimum": 0, + "readOnly": true }, - "last_scheduled_at": { + "location": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "readOnly": true }, - "last_success_at": { + "image_url": { "type": "string", - "format": "date-time", + "readOnly": true + }, + "approval_mode": { + "allOf": [ + { + "$ref": "#/components/schemas/ApprovalModeE35Enum" + } + ], + "readOnly": true + }, + "custom_form": { "readOnly": true, "nullable": true }, - "last_error": { - "type": "string", + "show_public_availability": { + "type": "boolean", "readOnly": true }, - "updated_at": { - "type": "string", - "format": "date-time", + "show_public_booker_names": { + "type": "boolean", "readOnly": true } }, "required": [ - "last_error", - "last_scheduled_at", - "last_success_at", - "updated_at" + "approval_mode", + "capacity", + "custom_form", + "description", + "image_url", + "kind", + "location", + "name", + "public_token", + "show_public_availability", + "show_public_booker_names" ] }, - "PlatformDashboard": { + "PublicBookingInput": { "type": "object", "properties": { - "checked_at": { + "certification_override_reason": { "type": "string", - "format": "date-time" - }, - "tenants_by_state": { - "type": "object", - "additionalProperties": { - "type": "integer" - } - }, - "usage_totals": { - "type": "object", - "additionalProperties": { - "type": "integer" - } - }, - "tenants": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PlatformTenantUsage" - } - }, - "backup": { - "$ref": "#/components/schemas/PlatformBackupHealth" + "writeOnly": true, + "maxLength": 1000 }, - "offsite": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PlatformOffsiteHealth" - } + "starts_at": { + "type": "string", + "format": "date-time", + "writeOnly": true }, - "scheduler": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PlatformTaskHealth" - } + "ends_at": { + "type": "string", + "format": "date-time", + "writeOnly": true }, - "failed_deliveries": { - "$ref": "#/components/schemas/FailedDeliveries" + "custom_answers": { + "writeOnly": true, + "nullable": true } }, "required": [ - "backup", - "checked_at", - "failed_deliveries", - "offsite", - "scheduler", - "tenants", - "tenants_by_state", - "usage_totals" + "ends_at", + "starts_at" ] }, - "PlatformEmailSettings": { + "PublicBookingResource": { "type": "object", "properties": { - "id": { - "type": "integer", + "public_token": { + "type": "string", + "format": "uuid", "readOnly": true }, - "smtp_host": { + "name": { "type": "string", - "maxLength": 200 + "readOnly": true }, - "smtp_port": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "kind": { + "allOf": [ + { + "$ref": "#/components/schemas/Kind3bfEnum" + } + ], + "readOnly": true }, - "smtp_username": { + "description": { "type": "string", - "maxLength": 200 + "readOnly": true }, - "smtp_password": { + "capacity": { + "type": "integer", + "minimum": 0, + "readOnly": true + }, + "location": { "type": "string", - "writeOnly": true + "readOnly": true }, - "smtp_password_set": { + "image_url": { + "type": "string" + }, + "approval_mode": { + "allOf": [ + { + "$ref": "#/components/schemas/ApprovalModeE35Enum" + } + ], + "readOnly": true + }, + "custom_form": { + "readOnly": true, + "nullable": true + }, + "show_public_availability": { "type": "boolean", "readOnly": true }, - "smtp_use_tls": { - "type": "boolean" + "show_public_booker_names": { + "type": "boolean", + "readOnly": true }, - "smtp_use_ssl": { - "type": "boolean" + "payment_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "from_email": { - "oneOf": [ - { - "type": "string", - "format": "email", - "maxLength": 254 - }, + "currency": { + "type": "string" + }, + "rules": { + "$ref": "#/components/schemas/BookingPublicRules" + }, + "website": { + "allOf": [ { - "type": "string", - "maxLength": 0 + "$ref": "#/components/schemas/BookingWebsite" } - ] - }, - "updated_at": { - "type": "string", - "format": "date-time", - "readOnly": true + ], + "nullable": true } }, "required": [ - "id", - "smtp_password_set", - "updated_at" + "approval_mode", + "capacity", + "currency", + "custom_form", + "description", + "image_url", + "kind", + "location", + "name", + "payment_amount", + "public_token", + "rules", + "show_public_availability", + "show_public_booker_names", + "website" ] }, - "PlatformEnum": { - "enum": [ - "apple", - "android" - ], - "type": "string", - "description": "* `apple` - Apple\n* `android` - Android" - }, - "PlatformOffsiteHealth": { + "PublicCategory": { "type": "object", "properties": { + "id": { + "type": "integer", + "readOnly": true + }, "name": { - "type": "string" + "type": "string", + "readOnly": true }, - "state": { - "type": "string" + "slug": { + "type": "string", + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "verified_at": { + "display_order": { + "type": "integer", + "readOnly": true + }, + "icon": { "type": "string", - "format": "date-time", - "nullable": true + "readOnly": true }, - "has_error": { - "type": "boolean" + "product_count": { + "type": "integer", + "readOnly": true } }, "required": [ - "has_error", + "display_order", + "icon", + "id", "name", - "state", - "verified_at" + "product_count", + "slug" ] }, - "PlatformProblem": { + "PublicConfig": { "type": "object", "properties": { - "key": { - "type": "string" + "email_enabled": { + "type": "boolean" }, - "status": { - "$ref": "#/components/schemas/Status00eEnum" + "public_image_max_bytes": { + "type": "integer" }, - "message": { - "type": "string" + "social_auth": { + "type": "object", + "additionalProperties": {} }, - "fix_url": { - "type": "string" + "phone_login": { + "type": "object", + "additionalProperties": {} }, - "makerspace_id": { - "type": "integer", - "nullable": true + "password_login": { + "type": "object", + "additionalProperties": {} + }, + "self_registration": { + "type": "object", + "additionalProperties": {} + }, + "member_accounts": { + "type": "object", + "additionalProperties": {} + }, + "native": { + "$ref": "#/components/schemas/NativeConfig" } }, "required": [ - "fix_url", - "key", - "makerspace_id", - "message", - "status" + "email_enabled", + "public_image_max_bytes" ] }, - "PlatformProblems": { + "PublicEvent": { "type": "object", "properties": { - "checked_at": { + "participating_organizations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParticipatingOrganization" + }, + "readOnly": true + }, + "audience_label": { "type": "string", - "format": "date-time" + "nullable": true, + "readOnly": true }, - "status": { - "$ref": "#/components/schemas/Status00eEnum" + "registration_audience": { + "allOf": [ + { + "$ref": "#/components/schemas/EventDefaultRegistrationAudienceEnum" + } + ], + "readOnly": true }, - "results": { + "category": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "readOnly": true + }, + "tags": { "type": "array", "items": { - "$ref": "#/components/schemas/PlatformProblem" - } - } - }, - "required": [ - "checked_at", - "results", - "status" - ] - }, - "PlatformSocialAuthSettings": { - "type": "object", - "properties": { - "google_enabled": { - "type": "boolean", + "type": "string" + }, "readOnly": true }, - "apple_enabled": { + "ticket_types": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "audience": { + "type": "string" + }, + "spots_left": { + "type": "integer", + "nullable": true + } + } + }, + "readOnly": true + }, + "promo_codes_enabled": { "type": "boolean", "readOnly": true }, - "github_enabled": { + "ticket_selection_required": { "type": "boolean", "readOnly": true }, - "google_web_client_id": { + "website": { + "allOf": [ + { + "$ref": "#/components/schemas/Website" + } + ], + "nullable": true, + "readOnly": true + }, + "public_token": { "type": "string", - "maxLength": 255 + "format": "uuid", + "readOnly": true }, - "google_ios_client_id": { + "title": { "type": "string", - "maxLength": 255 + "readOnly": true }, - "google_android_client_id": { + "description": { "type": "string", - "maxLength": 255 + "readOnly": true }, - "apple_service_id": { + "starts_at": { "type": "string", - "maxLength": 255 + "format": "date-time", + "readOnly": true }, - "apple_native_app_ids": {}, - "apple_team_id": { + "ends_at": { "type": "string", - "maxLength": 64 + "format": "date-time", + "readOnly": true }, - "apple_key_id": { + "location": { "type": "string", - "maxLength": 64 + "readOnly": true + }, + "location_kind": { + "allOf": [ + { + "$ref": "#/components/schemas/LocationKindEnum" + } + ], + "readOnly": true + }, + "custom_form": { + "readOnly": true, + "nullable": true + }, + "capacity": { + "type": "integer", + "minimum": 0, + "readOnly": true + }, + "show_registration_count": { + "type": "boolean", + "readOnly": true + }, + "registration_count": { + "type": "integer", + "nullable": true, + "readOnly": true + }, + "payment_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true }, - "apple_private_key": { + "currency": { "type": "string", - "writeOnly": true + "readOnly": true }, - "apple_private_key_set": { + "availability": { + "allOf": [ + { + "$ref": "#/components/schemas/AvailabilityEnum" + } + ], + "readOnly": true + }, + "registration_requires_approval": { "type": "boolean", "readOnly": true }, - "github_client_id": { + "effective_registration_cutoff_at": { "type": "string", - "maxLength": 255 + "format": "date-time", + "nullable": true, + "readOnly": true }, - "github_client_secret": { + "registration_open": { + "type": "boolean", + "readOnly": true + }, + "image_url": { "type": "string", - "writeOnly": true, - "maxLength": 4096 + "format": "uri", + "nullable": true, + "readOnly": true }, - "github_client_secret_set": { - "type": "boolean", + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusE90Enum" + } + ], "readOnly": true }, - "clear_github_client_secret": { - "type": "boolean", - "writeOnly": true + "organizers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventOrganizerSummary" + }, + "readOnly": true }, - "updated_at": { - "type": "string", - "format": "date-time", + "series": { + "type": "object", + "nullable": true, + "properties": { + "public_token": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + } + }, "readOnly": true } }, "required": [ - "apple_enabled", - "apple_private_key_set", - "github_client_secret_set", - "github_enabled", - "google_enabled", - "updated_at" + "audience_label", + "availability", + "capacity", + "category", + "currency", + "custom_form", + "description", + "effective_registration_cutoff_at", + "ends_at", + "image_url", + "location", + "location_kind", + "organizers", + "participating_organizations", + "payment_amount", + "promo_codes_enabled", + "public_token", + "registration_audience", + "registration_count", + "registration_open", + "registration_requires_approval", + "series", + "show_registration_count", + "starts_at", + "status", + "tags", + "ticket_selection_required", + "ticket_types", + "title", + "website" ] }, - "PlatformStripeConnectSettings": { + "PublicEventRegistrationInput": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "stripe_publishable_key": { - "type": "string", + "custom_answers": { "writeOnly": true, - "maxLength": 255 + "nullable": true }, - "stripe_publishable_key_set": { - "type": "boolean", - "readOnly": true + "host_waiver_id": { + "type": "integer", + "minimum": 1, + "writeOnly": true, + "nullable": true }, - "stripe_secret_key": { + "host_waiver_version": { "type": "string", - "writeOnly": true + "writeOnly": true, + "nullable": true, + "maxLength": 64 }, - "stripe_secret_key_set": { + "host_waiver_accepted": { "type": "boolean", - "readOnly": true + "writeOnly": true, + "default": false }, - "stripe_webhook_secret": { + "phone": { "type": "string", - "writeOnly": true + "writeOnly": true, + "maxLength": 64 }, - "stripe_webhook_secret_set": { - "type": "boolean", - "readOnly": true + "email": { + "writeOnly": true, + "oneOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "string", + "maxLength": 0 + } + ] }, - "stripe_connect_client_id": { + "invite_code": { "type": "string", - "maxLength": 255 + "writeOnly": true, + "maxLength": 128 }, - "application_fee_bps": { + "ticket_type_id": { "type": "integer", - "maximum": 10000, - "minimum": 0 + "minimum": 1, + "writeOnly": true }, - "updated_at": { + "promo_code": { "type": "string", - "format": "date-time", + "writeOnly": true, + "maxLength": 128 + } + } + }, + "PublicEventRegistrationResponse": { + "type": "object", + "properties": { + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/PublicEventRegistrationResponseStatusEnum" + } + ], "readOnly": true } }, "required": [ - "id", - "stripe_publishable_key_set", - "stripe_secret_key_set", - "stripe_webhook_secret_set", - "updated_at" + "status" ] }, - "PlatformTaskHealth": { + "PublicEventRegistrationResponseStatusEnum": { + "enum": [ + "pending_approval", + "registered", + "waitlisted" + ], + "type": "string", + "description": "* `pending_approval` - pending_approval\n* `registered` - registered\n* `waitlisted` - waitlisted" + }, + "PublicImageAttachRequest": { "type": "object", "properties": { - "name": { - "type": "string" - }, - "task": { - "type": "string" - }, - "cadence_minutes": { - "type": "integer" - }, - "last_run_at": { + "object_key": { "type": "string", - "format": "date-time", - "nullable": true - }, - "status": { - "$ref": "#/components/schemas/Status00eEnum" + "maxLength": 300 } }, "required": [ - "cadence_minutes", - "last_run_at", - "name", - "status", - "task" + "object_key" ] }, - "PlatformTenantUsage": { + "PublicImageUploadRequest": { "type": "object", "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "slug": { + "content_type": { "type": "string" }, - "usage": { - "$ref": "#/components/schemas/MakerspaceUsage" + "filename": { + "type": "string", + "maxLength": 255 } }, "required": [ - "id", - "name", - "slug", - "usage" + "content_type", + "filename" ] }, - "Policy": { - "type": "object", - "properties": { - "blind_default": { - "type": "boolean" - }, - "variance_threshold": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0, - "nullable": true - } - } - }, - "PreferenceCell": { + "PublicImageUploadResponse": { "type": "object", "properties": { - "feature": { - "$ref": "#/components/schemas/Feature9d8Enum" - }, - "channel": { - "$ref": "#/components/schemas/Channel673Enum" + "object_key": { + "type": "string" }, - "enabled": { - "type": "boolean" + "url": { + "type": "string", + "format": "uri" }, - "daily_digest": { - "type": "boolean" + "fields": { + "type": "object", + "additionalProperties": {} }, - "available": { - "type": "boolean" + "method": { + "type": "string" }, - "mandatory_events": { - "type": "array", - "items": { - "type": "string" - } + "headers": { + "type": "object", + "additionalProperties": {} } }, "required": [ - "available", - "channel", - "enabled", - "feature", - "mandatory_events" + "object_key", + "url" ] }, - "PreferenceResponse": { + "PublicInventoryError": { "type": "object", "properties": { - "cells": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PreferenceCell" - } + "detail": { + "type": "string" + }, + "module": { + "type": "string" + }, + "feature": { + "type": "string" + }, + "code": { + "type": "string" } - }, - "required": [ - "cells" - ] + } }, - "PresenceCurrent": { + "PublicLandingPage6a0Enum": { + "enum": [ + "auto", + "inventory", + "events", + "machines", + "bookings", + "membership" + ], + "type": "string", + "description": "* `auto` - Auto\n* `inventory` - Inventory\n* `events` - Events\n* `machines` - Machines\n* `bookings` - Bookings\n* `membership` - Membership" + }, + "PublicMachine": { "type": "object", + "description": "Allowlist-only public projection; never subclass the staff serializer.", "properties": { - "active": { - "type": "boolean" + "name": { + "type": "string", + "readOnly": true }, - "session": { + "machine_type": { "allOf": [ { - "$ref": "#/components/schemas/PresenceSession" + "$ref": "#/components/schemas/PublicMachineType" } ], - "nullable": true + "readOnly": true + }, + "image_url": { + "type": "string", + "format": "uri", + "nullable": true, + "readOnly": true + }, + "status": { + "type": "string", + "readOnly": true + }, + "usage_hours": { + "type": "string", + "format": "decimal", + "example": "12.50", + "readOnly": true } }, "required": [ - "active", - "session" + "image_url", + "machine_type", + "name", + "status", + "usage_hours" ] }, - "PresenceRoster": { + "PublicMachineDetail": { "type": "object", + "description": "Allowlist-only public projection; never subclass the staff serializer.", "properties": { - "display_name": { - "type": "string" + "name": { + "type": "string", + "readOnly": true }, - "role_label": { + "machine_type": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "icon": { + "type": "string" + } + }, + "readOnly": true + }, + "image_url": { + "type": "string", + "format": "uri", + "nullable": true, + "readOnly": true + }, + "status": { "type": "string", "readOnly": true }, - "started_at": { + "usage_hours": { "type": "string", - "format": "date-time" + "format": "decimal", + "readOnly": true }, - "expires_at": { + "id": { + "type": "integer", + "readOnly": true + }, + "status_since": { "type": "string", - "format": "date-time" + "format": "date-time", + "readOnly": true + }, + "training_required": { + "type": "boolean", + "nullable": true, + "readOnly": true + }, + "queue": { + "allOf": [ + { + "$ref": "#/components/schemas/PublicQueueEstimate" + } + ], + "nullable": true, + "readOnly": true } }, "required": [ - "display_name", - "expires_at", - "role_label", - "started_at" + "id", + "image_url", + "machine_type", + "name", + "queue", + "status", + "status_since", + "training_required", + "usage_hours" ] }, - "PresenceSession": { + "PublicMachineServiceSubmit": { "type": "object", "properties": { - "started_at": { + "certification_override_reason": { "type": "string", - "format": "date-time" + "writeOnly": true, + "maxLength": 1000 }, - "expires_at": { - "type": "string", - "format": "date-time" + "website": { + "type": "string" }, - "ended_at": { + "machine_id": { + "type": "integer", + "minimum": 1 + }, + "title": { "type": "string", - "format": "date-time", - "nullable": true + "maxLength": 200 }, - "end_reason": { + "description": { + "type": "string" + }, + "source_link": { "oneOf": [ { - "$ref": "#/components/schemas/EndReasonEnum" + "type": "string", + "format": "uri", + "maxLength": 200 }, { - "$ref": "#/components/schemas/BlankEnum" + "type": "string", + "maxLength": 0 } ] }, - "active": { - "type": "boolean", - "readOnly": true - } + "capability_payload": {} }, "required": [ - "active", - "expires_at", - "started_at" + "machine_id", + "title" ] }, - "PresenceStart": { + "PublicMachineServiceSubmitResponse": { "type": "object", "properties": { - "duration_minutes": { - "type": "integer", - "maximum": 480, - "minimum": 15 - }, - "latitude": { - "type": "number", - "format": "double", - "maximum": 90, - "minimum": -90 - }, - "longitude": { - "type": "number", - "format": "double", - "maximum": 180, - "minimum": -180 + "public_token": { + "type": "string", + "format": "uuid" }, - "accuracy": { - "type": "number", - "format": "double", - "minimum": 0 + "status": { + "type": "string" } }, "required": [ - "duration_minutes" + "public_token", + "status" ] }, - "PresetEnum": { - "enum": [ - "sheet", - "single" - ], - "type": "string", - "description": "* `sheet` - sheet\n* `single` - single" - }, - "PrinterPool": { + "PublicMachineType": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "machine_id": { - "type": "integer", - "readOnly": true, - "nullable": true - }, - "machine_type_id": { - "type": "integer", - "readOnly": true, - "nullable": true - }, - "material": { + "name": { "type": "string", - "maxLength": 100 + "readOnly": true }, - "color": { + "icon": { "type": "string", - "maxLength": 100 - }, - "color_hex": { + "readOnly": true + } + }, + "required": [ + "icon", + "name" + ] + }, + "PublicMakerspace": { + "type": "object", + "properties": { + "name": { "type": "string", - "maxLength": 7 + "readOnly": true }, - "brand": { + "public_code": { "type": "string", - "maxLength": 100 + "readOnly": true }, - "lot_code": { + "slug": { "type": "string", - "maxLength": 100 - }, - "unit": { - "$ref": "#/components/schemas/PrinterPoolUnitEnum" + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "initial_grams": { + "location": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "readOnly": true }, - "remaining_grams": { + "map_url": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", "readOnly": true }, - "low_threshold_grams": { + "logo_url": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "nullable": true - }, - "is_active": { - "type": "boolean", + "format": "uri", + "nullable": true, "readOnly": true }, - "is_public": { - "type": "boolean" - }, - "opened_at": { + "cover_image_url": { "type": "string", - "format": "date-time", + "format": "uri", + "nullable": true, + "readOnly": true + } + }, + "required": [ + "cover_image_url", + "location", + "logo_url", + "map_url", + "name", + "public_code", + "slug" + ] + }, + "PublicMaterial": { + "type": "object", + "properties": { + "id": { + "type": "integer", "nullable": true }, - "created_at": { + "name": { + "type": "string" + }, + "colours": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "colours", + "id", + "name" + ] + }, + "PublicOpeningInterval": { + "type": "object", + "properties": { + "starts_at": { "type": "string", "format": "date-time", "readOnly": true }, - "updated_at": { + "ends_at": { "type": "string", "format": "date-time", "readOnly": true } }, "required": [ - "created_at", - "id", - "initial_grams", - "is_active", - "machine_id", - "machine_type_id", - "material", - "remaining_grams", - "updated_at" + "ends_at", + "starts_at" ] }, - "PrinterPoolCorrection": { + "PublicOrganization": { "type": "object", "properties": { - "quantity_delta": { + "slug": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "reason": { - "type": "string" + "name": { + "type": "string", + "readOnly": true + }, + "description": { + "type": "string", + "readOnly": true + }, + "website": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "logo_url": { + "type": "string", + "format": "uri", + "nullable": true, + "readOnly": true + }, + "catalogue_links": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "readOnly": true } }, "required": [ - "quantity_delta", - "reason" + "catalogue_links", + "description", + "logo_url", + "name", + "slug", + "website" ] }, - "PrinterPoolCreate": { + "PublicOrganizationEvent": { "type": "object", "properties": { - "machine_id": { - "type": "integer", - "minimum": 1, - "nullable": true + "participating_organizations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParticipatingOrganization" + }, + "readOnly": true }, - "machine_type_id": { - "type": "integer", - "minimum": 1, - "nullable": true + "audience_label": { + "type": "string", + "nullable": true, + "readOnly": true }, - "is_public": { - "type": "boolean", - "default": true + "registration_audience": { + "allOf": [ + { + "$ref": "#/components/schemas/EventDefaultRegistrationAudienceEnum" + } + ], + "readOnly": true }, - "material": { - "type": "string", - "maxLength": 100 + "category": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "readOnly": true }, - "color": { - "type": "string", - "maxLength": 100 + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "readOnly": true }, - "color_hex": { - "type": "string", - "maxLength": 7 + "ticket_types": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "audience": { + "type": "string" + }, + "spots_left": { + "type": "integer", + "nullable": true + } + } + }, + "readOnly": true }, - "brand": { - "type": "string", - "maxLength": 100 + "promo_codes_enabled": { + "type": "boolean", + "readOnly": true }, - "lot_code": { - "type": "string", - "maxLength": 100 + "ticket_selection_required": { + "type": "boolean", + "readOnly": true }, - "unit": { + "website": { "allOf": [ { - "$ref": "#/components/schemas/PrinterPoolCreateUnitEnum" + "$ref": "#/components/schemas/Website" } ], - "default": "grams" + "nullable": true, + "readOnly": true }, - "quantity": { + "public_token": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "format": "uuid", + "readOnly": true }, - "initial_grams": { + "title": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "readOnly": true }, - "low_threshold_grams": { + "description": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "nullable": true - } - }, - "required": [ - "material" - ] - }, - "PrinterPoolCreateUnitEnum": { - "enum": [ - "grams", - "milliliters", - "millimeters", - "count" - ], - "type": "string", - "description": "* `grams` - grams\n* `milliliters` - milliliters\n* `millimeters` - millimeters\n* `count` - count" - }, - "PrinterPoolUnitEnum": { - "enum": [ - "grams", - "milliliters", - "millimeters", - "count" - ], - "type": "string", - "description": "* `grams` - Grams\n* `milliliters` - Milliliters\n* `millimeters` - Millimeters\n* `count` - Count" - }, - "PrinterServiceReport": { - "type": "object", - "properties": { - "printer_metrics": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PrinterServiceReportRow" - } - } - }, - "required": [ - "printer_metrics" - ] - }, - "PrinterServiceReportRow": { - "type": "object", - "properties": { - "makerspace_id": { - "type": "integer" + "readOnly": true }, - "machine_id": { - "type": "integer" + "starts_at": { + "type": "string", + "format": "date-time", + "readOnly": true }, - "machine_name": { - "type": "string" + "ends_at": { + "type": "string", + "format": "date-time", + "readOnly": true }, - "model": { - "type": "string" + "location": { + "type": "string", + "readOnly": true }, - "completed_hours": { - "type": "number", - "format": "double" + "location_kind": { + "allOf": [ + { + "$ref": "#/components/schemas/LocationKindEnum" + } + ], + "readOnly": true }, - "failed_partial_hours": { - "type": "number", - "format": "double" + "custom_form": { + "readOnly": true, + "nullable": true }, - "manual_hours": { - "type": "number", - "format": "double" + "capacity": { + "type": "integer", + "minimum": 0, + "readOnly": true }, - "consumed_grams": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "show_registration_count": { + "type": "boolean", + "readOnly": true }, - "payment_due": { + "registration_count": { + "type": "integer", + "nullable": true, + "readOnly": true + }, + "payment_amount": { "type": "string", "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true }, - "payment_paid": { + "currency": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - } - }, - "required": [ - "completed_hours", - "consumed_grams", - "failed_partial_hours", - "machine_id", - "machine_name", - "manual_hours", - "model", - "payment_due", - "payment_paid" - ] - }, - "ProblemReportTriage": { - "type": "object", - "properties": { - "outcome": { - "$ref": "#/components/schemas/ProblemReportTriageOutcomeEnum" + "readOnly": true }, - "resolutions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProblemReportTriageResolution" - } + "availability": { + "allOf": [ + { + "$ref": "#/components/schemas/AvailabilityEnum" + } + ], + "readOnly": true }, - "note": { + "registration_requires_approval": { + "type": "boolean", + "readOnly": true + }, + "effective_registration_cutoff_at": { "type": "string", - "default": "" + "format": "date-time", + "nullable": true, + "readOnly": true }, - "evidence_id": { - "type": "integer", - "nullable": true - } - }, - "required": [ - "outcome" - ] - }, - "ProblemReportTriageOutcomeEnum": { - "enum": [ - "no_issue", - "damaged", - "missing", - "needs_fix" - ], - "type": "string", - "description": "* `no_issue` - no_issue\n* `damaged` - damaged\n* `missing` - missing\n* `needs_fix` - needs_fix" - }, - "ProblemReportTriageResolution": { - "type": "object", - "properties": { - "item_id": { - "type": "integer" + "registration_open": { + "type": "boolean", + "readOnly": true }, - "quantity": { - "type": "integer", - "minimum": 1 - } - }, - "required": [ - "item_id", - "quantity" - ] - }, - "ProblemReportTriageResponse": { - "type": "object", - "properties": { - "id": { - "type": "integer" + "image_url": { + "type": "string", + "format": "uri", + "nullable": true, + "readOnly": true }, - "outcome": { - "type": "string" + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusE90Enum" + } + ], + "readOnly": true }, - "resolved": { - "type": "boolean" + "organizers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventOrganizerSummary" + }, + "readOnly": true + }, + "series": { + "type": "object", + "nullable": true, + "properties": { + "public_token": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + } + }, + "readOnly": true + }, + "host": { + "allOf": [ + { + "$ref": "#/components/schemas/OrganizationEventHost" + } + ], + "readOnly": true } }, "required": [ - "id", - "outcome", - "resolved" + "audience_label", + "availability", + "capacity", + "category", + "currency", + "custom_form", + "description", + "effective_registration_cutoff_at", + "ends_at", + "host", + "image_url", + "location", + "location_kind", + "organizers", + "participating_organizations", + "payment_amount", + "promo_codes_enabled", + "public_token", + "registration_audience", + "registration_count", + "registration_open", + "registration_requires_approval", + "series", + "show_registration_count", + "starts_at", + "status", + "tags", + "ticket_selection_required", + "ticket_types", + "title", + "website" ] }, - "ProductQrHistory": { + "PublicOrganizationEventList": { "type": "object", "properties": { - "product": { + "count": { "type": "integer" }, - "scans": { + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { "type": "array", "items": { - "$ref": "#/components/schemas/QrHistoryItem" + "$ref": "#/components/schemas/PublicOrganizationEvent" } } }, "required": [ - "product", - "scans" + "count", + "next", + "previous", + "results" ] }, - "ProfileActivity": { + "PublicPrintStatusLookupPolicyEnum": { + "enum": [ + "token_only", + "email_unverified" + ], + "type": "string", + "description": "* `token_only` - Token only\n* `email_unverified` - Email unverified" + }, + "PublicPrinterPool": { "type": "object", "properties": { - "events_attended": { - "type": "integer" - }, - "events_registered": { + "id": { "type": "integer" }, - "recent_attended_events": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AttendedEvent" - } - } - } - }, - "ProfileError": { - "type": "object", - "properties": { - "code": { + "material": { "type": "string" }, - "detail": { + "color": { "type": "string" - }, - "fields": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "string" - } - } } }, "required": [ - "code", - "detail", - "fields" + "color", + "id", + "material" ] }, - "ProfileGateError": { + "PublicPrinterQueue": { "type": "object", "properties": { - "code": { - "type": "string" + "machine_type_id": { + "type": "integer" }, - "detail": { + "id": { + "type": "integer" + }, + "name": { "type": "string" }, - "purpose": { + "description": { "type": "string" } }, "required": [ - "code", - "detail" + "description", + "id", + "machine_type_id", + "name" ] }, - "ProfileImageAttachRequest": { + "PublicPrinterStatus": { "type": "object", + "description": "The token endpoint's complete field allowlist. Do not add contact fields.", "properties": { - "object_key": { + "public_token": { "type": "string", - "maxLength": 300 + "format": "uuid" }, - "project_id": { - "type": "integer" - } - }, - "required": [ - "object_key" - ] - }, - "ProfileImageUploadRequest": { - "type": "object", - "properties": { - "content_type": { + "status": { "type": "string" }, - "filename": { - "type": "string", - "maxLength": 255 - }, - "project_id": { - "type": "integer" - } - }, - "required": [ - "content_type", - "filename" - ] - }, - "ProfileRead": { - "type": "object", - "properties": { - "membership_id": { - "type": "integer" - }, - "display_name": { + "title": { "type": "string" }, - "is_visible": { - "type": "boolean" - }, - "show_attended_events": { - "type": "boolean" - }, - "show_certifications": { - "type": "boolean" - }, - "certifications": { - "type": "array", - "items": { - "type": "string" - } - }, - "headline": { - "type": "string" + "created_at": { + "type": "string", + "format": "date-time" }, - "institution": { - "type": "string" + "accepted_at": { + "type": "string", + "format": "date-time", + "nullable": true }, - "bio": { - "type": "string" + "started_at": { + "type": "string", + "format": "date-time", + "nullable": true }, - "avatar_url": { + "completed_at": { "type": "string", + "format": "date-time", "nullable": true }, - "interests": {}, - "languages": {}, - "education": {}, - "github_username": { - "type": "string" + "estimated_minutes": { + "type": "integer" }, - "github_contributions": { + "queue_position": { "type": "integer", - "nullable": true + "nullable": true, + "readOnly": true }, - "projects": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProjectRead" - } + "queue_approved_ahead": { + "type": "integer", + "nullable": true, + "readOnly": true }, - "activity": { - "$ref": "#/components/schemas/ProfileActivity" + "queue_awaiting_review_ahead": { + "type": "integer", + "nullable": true, + "readOnly": true } }, "required": [ - "activity", - "avatar_url", - "bio", - "certifications", - "display_name", - "education", - "github_contributions", - "github_username", - "headline", - "institution", - "interests", - "is_visible", - "languages", - "membership_id", - "projects", - "show_attended_events", - "show_certifications" + "accepted_at", + "completed_at", + "created_at", + "estimated_minutes", + "public_token", + "queue_approved_ahead", + "queue_awaiting_review_ahead", + "queue_position", + "started_at", + "status", + "title" ] }, - "ProfileWrite": { + "PublicPrinterSubmit": { "type": "object", "properties": { - "is_visible": { - "type": "boolean" + "certification_override_reason": { + "type": "string", + "writeOnly": true, + "maxLength": 1000 }, - "show_attended_events": { - "type": "boolean" + "website": { + "type": "string" }, - "show_certifications": { - "type": "boolean" + "queue_id": { + "type": "integer", + "nullable": true }, - "headline": { + "title": { "type": "string", "maxLength": 200 }, - "institution": { + "project_brief": { + "type": "string" + }, + "preferred_settings": { + "nullable": true + }, + "material": { "type": "string", - "maxLength": 200 + "maxLength": 100 }, - "bio": { + "color": { "type": "string", - "maxLength": 2000 + "maxLength": 100 }, - "interests": { - "type": "array", - "items": { - "type": "string", - "maxLength": 60 - }, - "maxItems": 20 + "consumable_pool_id": { + "type": "integer", + "nullable": true }, - "languages": { - "type": "array", - "items": { - "type": "string", - "maxLength": 60 - }, - "maxItems": 10 + "estimated_filament_grams": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,6}(?:\\.\\d{0,2})?$", + "nullable": true }, - "education": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EducationEntry" - }, - "maxItems": 10 + "quantity": { + "type": "integer", + "minimum": 1, + "default": 1 }, - "github_username": { + "source_link": { "oneOf": [ { "type": "string", - "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$" + "format": "uri", + "maxLength": 200 }, { "type": "string", @@ -95596,277 +118749,216 @@ } ] }, - "projects": { + "file_ids": { "type": "array", "items": { - "$ref": "#/components/schemas/ProjectWrite" - }, - "maxItems": 20 - } - } - }, - "ProjectLink": { - "type": "object", - "properties": { - "label": { - "type": "string", - "maxLength": 60 - }, - "url": { - "type": "string", - "format": "uri", - "maxLength": 500 + "type": "integer" + } } }, "required": [ - "label", - "url" + "title" ] }, - "ProjectRead": { + "PublicPrinterSubmitResponse": { "type": "object", "properties": { - "id": { - "type": "integer" - }, - "title": { - "type": "string" + "public_token": { + "type": "string", + "format": "uuid" }, - "description": { + "status": { "type": "string" - }, - "links": {}, - "image_url": { - "type": "string", - "nullable": true } }, "required": [ - "description", - "id", - "image_url", - "links", - "title" + "public_token", + "status" ] }, - "ProjectWrite": { + "PublicPrinterUpload": { "type": "object", "properties": { - "id": { - "type": "integer" + "queue_id": { + "type": "integer", + "nullable": true }, - "title": { - "type": "string", - "maxLength": 200 + "kind": { + "$ref": "#/components/schemas/PublicPrinterUploadKindEnum" }, - "description": { + "filename": { "type": "string", - "default": "", - "maxLength": 4000 + "maxLength": 255 }, - "links": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProjectLink" - }, - "maxItems": 10 + "content_type": { + "type": "string", + "maxLength": 128 } }, "required": [ - "title" + "filename", + "kind" ] }, - "Promo": { + "PublicPrinterUploadKindEnum": { + "enum": [ + "stl", + "screenshot" + ], + "type": "string", + "description": "* `stl` - stl\n* `screenshot` - screenshot" + }, + "PublicProduct": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "percent_off": { - "type": "integer", - "readOnly": true - }, - "amount_off": { + "name": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", "readOnly": true }, - "max_uses": { - "type": "integer", + "description": { + "type": "string", "readOnly": true }, - "uses": { + "category_id": { "type": "integer", - "readOnly": true - }, - "expires_at": { - "type": "string", - "format": "date-time", "readOnly": true, "nullable": true }, - "revoked_at": { + "category_name": { "type": "string", - "format": "date-time", "readOnly": true, "nullable": true }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "code": { + "category_slug": { "type": "string", "readOnly": true, - "description": "Returned once on creation only." - } - }, - "required": [ - "amount_off", - "code", - "created_at", - "expires_at", - "id", - "max_uses", - "percent_off", - "revoked_at", - "uses" - ] - }, - "PromoInput": { - "type": "object", - "properties": { - "code": { - "type": "string", - "writeOnly": true, - "maxLength": 128, - "minLength": 8 - }, - "percent_off": { - "type": "integer", - "maximum": 100, - "minimum": 0, - "default": 0 - }, - "amount_off": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "default": "0.00" - }, - "max_uses": { - "type": "integer", - "minimum": 1, - "default": 1 - }, - "expires_at": { - "type": "string", - "format": "date-time", "nullable": true - } - } - }, - "PromoList": { - "type": "object", - "properties": { - "count": { - "type": "integer" }, - "next": { + "borrowing_terms": { "type": "string", - "format": "uri", - "nullable": true + "readOnly": true + }, + "availability": { + "type": "object", + "nullable": true, + "properties": { + "mode": { + "type": "string", + "enum": [ + "status_only", + "exact_count" + ] + }, + "label": { + "type": "string", + "enum": [ + "Available", + "Limited", + "Unavailable" + ] + }, + "count": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "mode" + ], + "readOnly": true }, - "previous": { + "image_url": { "type": "string", "format": "uri", - "nullable": true - }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Promo" - } + "nullable": true, + "readOnly": true } }, "required": [ - "count", - "next", - "previous", - "results" + "availability", + "borrowing_terms", + "category_id", + "category_name", + "category_slug", + "description", + "id", + "image_url", + "name" ] }, - "ProvisionSubdomainRequest": { + "PublicQueueEstimate": { "type": "object", "properties": { - "label": { - "type": "string", - "maxLength": 63 + "length": { + "type": "integer", + "minimum": 0 + }, + "eta_minutes": { + "type": "integer", + "minimum": 0, + "nullable": true } }, "required": [ - "label" + "eta_minutes", + "length" ] }, - "ProvisionSubdomainValidationError": { + "PublicRequestItemStatus": { "type": "object", "properties": { - "label": { - "type": "array", - "items": { - "type": "string" - } + "product_name": { + "type": "string", + "readOnly": true + }, + "requested_quantity": { + "type": "integer", + "readOnly": true } }, "required": [ - "label" + "product_name", + "requested_quantity" ] }, - "PublicAdmission": { + "PublicRequestStatus": { "type": "object", "properties": { - "join_mode": { - "type": "string" - }, - "vouches_required": { - "type": "integer" - }, - "application_schema": { - "nullable": true - }, - "default_plan": { - "type": "object", - "additionalProperties": {}, - "nullable": true - }, - "joining_fee": { + "status": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "readOnly": true }, - "joining_fee_currency": { - "type": "string" + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true }, - "payment_timing": { - "type": "string" + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicRequestItemStatus" + }, + "readOnly": true } }, "required": [ - "application_schema", - "default_plan", - "join_mode", - "joining_fee", - "joining_fee_currency", - "payment_timing", - "vouches_required" + "created_at", + "items", + "status" ] }, - "PublicAvailabilityInterval": { + "PublicSpaceAvailability": { "type": "object", "properties": { + "public_token": { + "type": "string", + "format": "uuid", + "readOnly": true + }, "starts_at": { "type": "string", "format": "date-time", @@ -95877,3265 +118969,3229 @@ "format": "date-time", "readOnly": true }, - "booker_name": { + "timezone": { "type": "string", + "readOnly": true + }, + "opening_intervals": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicOpeningInterval" + }, + "readOnly": true, + "description": "Open windows in UTC, accounting for makerspace hours and dated exceptions." + }, + "availability": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicAvailabilityInterval" + }, "readOnly": true, "nullable": true } }, "required": [ - "booker_name", + "availability", "ends_at", - "starts_at" + "opening_intervals", + "public_token", + "starts_at", + "timezone" ] }, - "PublicAvailabilityModeFc2Enum": { - "enum": [ - "exact_count", - "status_only", - "hidden" - ], - "type": "string", - "description": "* `exact_count` - Exact Count\n* `status_only` - Status Only\n* `hidden` - Hidden" - }, - "PublicBookableSpace": { + "PublicStats": { "type": "object", "properties": { - "public_token": { - "type": "string", - "format": "uuid", - "readOnly": true - }, - "name": { - "type": "string", - "readOnly": true - }, - "kind": { + "machines": { "allOf": [ { - "$ref": "#/components/schemas/Kind3bfEnum" + "$ref": "#/components/schemas/PublicStatsMachines" } ], - "readOnly": true - }, - "description": { - "type": "string", - "readOnly": true - }, - "capacity": { - "type": "integer", - "minimum": 0, - "readOnly": true - }, - "location": { - "type": "string", - "readOnly": true - }, - "image_url": { - "type": "string", - "readOnly": true + "readOnly": true, + "nullable": true }, - "approval_mode": { + "printing": { "allOf": [ { - "$ref": "#/components/schemas/ApprovalModeE35Enum" + "$ref": "#/components/schemas/PublicStatsPrinting" } ], - "readOnly": true - }, - "custom_form": { "readOnly": true, "nullable": true }, - "show_public_availability": { - "type": "boolean", + "hardware": { + "allOf": [ + { + "$ref": "#/components/schemas/PublicStatsHardware" + } + ], "readOnly": true }, - "show_public_booker_names": { - "type": "boolean", + "current_loans": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicStatsCurrentLoan" + }, "readOnly": true } }, "required": [ - "approval_mode", - "capacity", - "custom_form", - "description", - "image_url", - "kind", - "location", - "name", - "public_token", - "show_public_availability", - "show_public_booker_names" + "current_loans", + "hardware", + "machines", + "printing" ] }, - "PublicBookingInput": { + "PublicStatsBrand": { "type": "object", "properties": { - "certification_override_reason": { - "type": "string", - "writeOnly": true, - "maxLength": 1000 - }, - "starts_at": { - "type": "string", - "format": "date-time", - "writeOnly": true - }, - "ends_at": { + "brand": { "type": "string", - "format": "date-time", - "writeOnly": true + "readOnly": true }, - "custom_answers": { - "writeOnly": true, - "nullable": true + "grams": { + "type": "number", + "format": "double", + "readOnly": true } }, "required": [ - "ends_at", - "starts_at" + "brand", + "grams" ] }, - "PublicBookingResponse": { + "PublicStatsBusiestPrinter": { "type": "object", "properties": { - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/PublicBookingResponseStatusEnum" - } - ], + "name": { + "type": "string", + "readOnly": true + }, + "model": { + "type": "string", + "readOnly": true + }, + "hours": { + "type": "number", + "format": "double", + "readOnly": true + }, + "completed": { + "type": "integer", "readOnly": true + }, + "image_url": { + "type": "string", + "format": "uri", + "readOnly": true, + "nullable": true } }, "required": [ - "status" + "completed", + "hours", + "image_url", + "model", + "name" ] }, - "PublicBookingResponseStatusEnum": { - "enum": [ - "pending", - "confirmed" - ], - "type": "string", - "description": "* `pending` - pending\n* `confirmed` - confirmed" - }, - "PublicCategory": { + "PublicStatsCurrentLoan": { "type": "object", "properties": { - "id": { - "type": "integer", + "item_name": { + "type": "string", "readOnly": true }, - "name": { + "holder_name": { "type": "string", "readOnly": true }, - "slug": { + "due": { "type": "string", + "format": "date-time", "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" - }, - "display_order": { - "type": "integer", - "readOnly": true + "nullable": true }, - "icon": { + "since": { "type": "string", - "readOnly": true - }, - "product_count": { - "type": "integer", - "readOnly": true + "format": "date-time", + "readOnly": true, + "nullable": true } }, "required": [ - "display_order", - "icon", - "id", - "name", - "product_count", - "slug" + "due", + "holder_name", + "item_name", + "since" ] }, - "PublicConfig": { + "PublicStatsFilamentTrend": { "type": "object", "properties": { - "email_enabled": { - "type": "boolean" - }, - "public_image_max_bytes": { - "type": "integer" - }, - "social_auth": { - "type": "object", - "additionalProperties": {} - }, - "phone_login": { - "type": "object", - "additionalProperties": {} - }, - "password_login": { - "type": "object", - "additionalProperties": {} - }, - "self_registration": { - "type": "object", - "additionalProperties": {} + "period": { + "type": "string", + "readOnly": true }, - "member_accounts": { - "type": "object", - "additionalProperties": {} + "grams": { + "type": "number", + "format": "double", + "readOnly": true } }, "required": [ - "email_enabled", - "public_image_max_bytes" + "grams", + "period" ] }, - "PublicEvent": { + "PublicStatsHardware": { "type": "object", "properties": { - "registration_audience": { - "allOf": [ - { - "$ref": "#/components/schemas/EventDefaultRegistrationAudienceEnum" - } - ], - "readOnly": true - }, - "category": { - "type": "object", - "nullable": true, - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "slug": { - "type": "string" - } + "most_popular": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicStatsPopularHardware" }, "readOnly": true }, - "tags": { + "tools_out": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/PublicStatsToolsOut" }, "readOnly": true }, - "ticket_types": { + "library": { + "allOf": [ + { + "$ref": "#/components/schemas/PublicStatsLibrary" + } + ], + "readOnly": true + }, + "recently_added": { "type": "array", "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "amount": { - "type": "string" - }, - "audience": { - "type": "string" - }, - "spots_left": { - "type": "integer", - "nullable": true - } - } + "$ref": "#/components/schemas/PublicStatsRecentlyAdded" }, "readOnly": true - }, - "promo_codes_enabled": { - "type": "boolean", + } + }, + "required": [ + "library", + "most_popular", + "recently_added", + "tools_out" + ] + }, + "PublicStatsJobs": { + "type": "object", + "properties": { + "completed": { + "type": "integer", "readOnly": true }, - "ticket_selection_required": { - "type": "boolean", + "status_counts": { + "allOf": [ + { + "$ref": "#/components/schemas/PublicStatsStatusCounts" + } + ], "readOnly": true }, - "public_token": { - "type": "string", - "format": "uuid", + "queue": { + "allOf": [ + { + "$ref": "#/components/schemas/PublicStatsQueue" + } + ], + "readOnly": true + } + }, + "required": [ + "completed", + "queue", + "status_counts" + ] + }, + "PublicStatsLibrary": { + "type": "object", + "properties": { + "currently_out_count": { + "type": "integer", "readOnly": true }, - "title": { - "type": "string", + "library_size": { + "type": "integer", "readOnly": true }, - "description": { + "available_count": { + "type": "integer", + "readOnly": true + } + }, + "required": [ + "available_count", + "currently_out_count", + "library_size" + ] + }, + "PublicStatsMachines": { + "type": "object", + "properties": { + "usage_hours": { + "type": "number", + "format": "double", + "readOnly": true + } + }, + "required": [ + "usage_hours" + ] + }, + "PublicStatsPopularHardware": { + "type": "object", + "properties": { + "name": { "type": "string", "readOnly": true }, - "starts_at": { - "type": "string", - "format": "date-time", + "times_lent": { + "type": "integer", "readOnly": true }, - "ends_at": { + "total_quantity_lent": { + "type": "integer", + "readOnly": true + } + }, + "required": [ + "name", + "times_lent", + "total_quantity_lent" + ] + }, + "PublicStatsPrinter": { + "type": "object", + "properties": { + "name": { "type": "string", - "format": "date-time", "readOnly": true }, - "location": { + "model": { "type": "string", "readOnly": true }, - "location_kind": { - "allOf": [ - { - "$ref": "#/components/schemas/LocationKindEnum" - } - ], + "jobs": { + "type": "integer", "readOnly": true }, - "custom_form": { - "readOnly": true, - "nullable": true + "hours": { + "type": "number", + "format": "double", + "readOnly": true }, - "capacity": { - "type": "integer", - "minimum": 0, + "grams": { + "type": "number", + "format": "double", "readOnly": true }, - "payment_amount": { + "image_url": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "format": "uri", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "grams", + "hours", + "image_url", + "jobs", + "model", + "name" + ] + }, + "PublicStatsPrinting": { + "type": "object", + "properties": { + "hours_all_time": { + "type": "number", + "format": "double", "readOnly": true }, - "currency": { - "type": "string", + "hours_this_month": { + "type": "number", + "format": "double", "readOnly": true }, - "availability": { + "busiest_printer": { "allOf": [ { - "$ref": "#/components/schemas/AvailabilityEnum" + "$ref": "#/components/schemas/PublicStatsBusiestPrinter" } ], - "readOnly": true - }, - "registration_requires_approval": { - "type": "boolean", - "readOnly": true + "readOnly": true, + "nullable": true }, - "effective_registration_cutoff_at": { - "type": "string", - "format": "date-time", - "nullable": true, + "per_printer": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicStatsPrinter" + }, "readOnly": true }, - "registration_open": { - "type": "boolean", + "grams_all_time": { + "type": "number", + "format": "double", "readOnly": true }, - "image_url": { - "type": "string", - "format": "uri", - "nullable": true, + "by_brand": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicStatsBrand" + }, "readOnly": true }, - "status": { + "jobs": { "allOf": [ { - "$ref": "#/components/schemas/StatusE90Enum" + "$ref": "#/components/schemas/PublicStatsJobs" } ], "readOnly": true }, - "organizers": { + "filament_trend": { "type": "array", "items": { - "$ref": "#/components/schemas/EventOrganizerSummary" - }, - "readOnly": true - }, - "series": { - "type": "object", - "nullable": true, - "properties": { - "public_token": { - "type": "string", - "format": "uuid" - }, - "title": { - "type": "string" - } + "$ref": "#/components/schemas/PublicStatsFilamentTrend" }, "readOnly": true } }, "required": [ - "availability", - "capacity", - "category", - "currency", - "custom_form", - "description", - "effective_registration_cutoff_at", - "ends_at", - "image_url", - "location", - "location_kind", - "organizers", - "payment_amount", - "promo_codes_enabled", - "public_token", - "registration_audience", - "registration_open", - "registration_requires_approval", - "series", - "starts_at", - "status", - "tags", - "ticket_selection_required", - "ticket_types", - "title" + "busiest_printer", + "by_brand", + "filament_trend", + "grams_all_time", + "hours_all_time", + "hours_this_month", + "jobs", + "per_printer" ] }, - "PublicEventRegistrationInput": { + "PublicStatsQueue": { "type": "object", "properties": { - "custom_answers": { - "writeOnly": true, - "nullable": true - }, - "phone": { - "type": "string", - "writeOnly": true, - "maxLength": 64 - }, - "email": { - "writeOnly": true, - "oneOf": [ - { - "type": "string", - "format": "email" - }, - { - "type": "string", - "maxLength": 0 - } - ] - }, - "invite_code": { - "type": "string", - "writeOnly": true, - "maxLength": 128 + "pending": { + "type": "integer", + "readOnly": true }, - "ticket_type_id": { + "accepted": { "type": "integer", - "minimum": 1, - "writeOnly": true + "readOnly": true }, - "promo_code": { - "type": "string", - "writeOnly": true, - "maxLength": 128 - } - } - }, - "PublicEventRegistrationResponse": { - "type": "object", - "properties": { - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/PublicEventRegistrationResponseStatusEnum" - } - ], + "printing": { + "type": "integer", "readOnly": true } }, "required": [ - "status" + "accepted", + "pending", + "printing" ] }, - "PublicEventRegistrationResponseStatusEnum": { - "enum": [ - "pending_approval", - "registered", - "waitlisted" - ], - "type": "string", - "description": "* `pending_approval` - pending_approval\n* `registered` - registered\n* `waitlisted` - waitlisted" - }, - "PublicImageAttachRequest": { + "PublicStatsRecentlyAdded": { "type": "object", "properties": { - "object_key": { + "name": { "type": "string", - "maxLength": 300 + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true } }, "required": [ - "object_key" + "created_at", + "name" ] }, - "PublicImageUploadRequest": { + "PublicStatsStatusCounts": { "type": "object", "properties": { - "content_type": { - "type": "string" + "pending": { + "type": "integer", + "readOnly": true }, - "filename": { - "type": "string", - "maxLength": 255 + "accepted": { + "type": "integer", + "readOnly": true + }, + "printing": { + "type": "integer", + "readOnly": true + }, + "completed": { + "type": "integer", + "readOnly": true + }, + "collected": { + "type": "integer", + "readOnly": true + }, + "failed": { + "type": "integer", + "readOnly": true + }, + "rejected": { + "type": "integer", + "readOnly": true } }, "required": [ - "content_type", - "filename" + "accepted", + "collected", + "completed", + "failed", + "pending", + "printing", + "rejected" ] }, - "PublicImageUploadResponse": { + "PublicStatsToolsOut": { "type": "object", "properties": { - "object_key": { - "type": "string" - }, - "url": { + "name": { "type": "string", - "format": "uri" - }, - "fields": { - "type": "object", - "additionalProperties": {} - }, - "method": { - "type": "string" + "readOnly": true }, - "headers": { - "type": "object", - "additionalProperties": {} + "quantity_out": { + "type": "integer", + "readOnly": true } }, "required": [ - "object_key", - "url" + "name", + "quantity_out" ] }, - "PublicInventoryError": { + "PublicToolCheckout": { "type": "object", "properties": { - "detail": { - "type": "string" + "kit_checklist": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KitCount" + }, + "maxItems": 3550 }, - "module": { - "type": "string" + "payload": { + "type": "string", + "maxLength": 64 }, - "feature": { - "type": "string" + "evidence_id": { + "type": "integer" }, - "code": { + "remark": { "type": "string" } - } - }, - "PublicLandingPage6a0Enum": { - "enum": [ - "auto", - "inventory", - "events", - "machines", - "bookings", - "membership" - ], - "type": "string", - "description": "* `auto` - Auto\n* `inventory` - Inventory\n* `events` - Events\n* `machines` - Machines\n* `bookings` - Bookings\n* `membership` - Membership" + }, + "required": [ + "evidence_id", + "payload" + ] }, - "PublicMachine": { + "PublicToolEvidenceUrlRequest": { "type": "object", - "description": "Allowlist-only public projection; never subclass the staff serializer.", "properties": { - "name": { - "type": "string", - "readOnly": true - }, - "machine_type": { - "allOf": [ - { - "$ref": "#/components/schemas/PublicMachineType" - } - ], - "readOnly": true - }, - "image_url": { - "type": "string", - "format": "uri", - "nullable": true, - "readOnly": true + "evidence_type": { + "$ref": "#/components/schemas/EvidenceType740Enum" }, - "status": { - "type": "string", - "readOnly": true + "content_type": { + "type": "string" }, - "usage_hours": { - "type": "string", - "format": "decimal", - "example": "12.50", - "readOnly": true + "size_bytes": { + "type": "integer", + "minimum": 0, + "nullable": true } }, "required": [ - "image_url", - "machine_type", - "name", - "status", - "usage_hours" + "content_type", + "evidence_type" ] }, - "PublicMachineDetail": { + "PublicToolLoan": { "type": "object", - "description": "Allowlist-only public projection; never subclass the staff serializer.", "properties": { - "name": { - "type": "string", - "readOnly": true - }, - "machine_type": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "icon": { - "type": "string" - } - }, + "has_kit_checklist": { + "type": "boolean", "readOnly": true }, - "image_url": { + "public_token": { "type": "string", - "format": "uri", - "nullable": true, + "format": "uuid", "readOnly": true }, "status": { "type": "string", "readOnly": true }, - "usage_hours": { - "type": "string", - "format": "decimal", - "readOnly": true - }, - "id": { - "type": "integer", - "readOnly": true - }, - "status_since": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "training_required": { - "type": "boolean", - "nullable": true, - "readOnly": true - }, - "queue": { - "allOf": [ - { - "$ref": "#/components/schemas/PublicQueueEstimate" - } - ], - "nullable": true, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicToolLoanItem" + }, "readOnly": true } }, "required": [ - "id", - "image_url", - "machine_type", - "name", - "queue", - "status", - "status_since", - "training_required", - "usage_hours" + "has_kit_checklist", + "items", + "public_token", + "status" ] }, - "PublicMachineServiceSubmit": { + "PublicToolLoanItem": { "type": "object", "properties": { - "certification_override_reason": { - "type": "string", - "writeOnly": true, - "maxLength": 1000 - }, - "website": { - "type": "string" - }, - "machine_id": { - "type": "integer", - "minimum": 1 - }, - "title": { - "type": "string", - "maxLength": 200 - }, - "description": { + "product_name": { "type": "string" }, - "source_link": { - "oneOf": [ - { - "type": "string", - "format": "uri", - "maxLength": 200 - }, - { - "type": "string", - "maxLength": 0 - } - ] - }, - "capability_payload": {} + "quantity": { + "type": "integer" + } }, "required": [ - "machine_id", - "title" + "product_name", + "quantity" ] }, - "PublicMachineServiceSubmitResponse": { + "PublicToolScan": { "type": "object", "properties": { - "public_token": { + "kit_checklist": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KitCount" + }, + "maxItems": 3550 + }, + "payload": { "type": "string", - "format": "uuid" + "maxLength": 64 }, - "status": { + "evidence_id": { + "type": "integer" + }, + "remark": { "type": "string" + }, + "report_problem": { + "type": "boolean", + "default": false + }, + "problem_note": { + "type": "string", + "default": "" } }, "required": [ - "public_token", - "status" + "evidence_id", + "payload", + "remark" ] }, - "PublicMachineType": { + "PublicTypeIntake": { "type": "object", "properties": { - "name": { - "type": "string", - "readOnly": true + "materials": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicMaterial" + } }, - "icon": { - "type": "string", - "readOnly": true - } + "intake_schema": {} }, "required": [ - "icon", - "name" + "intake_schema", + "materials" ] }, - "PublicMakerspace": { + "PushDevice": { "type": "object", "properties": { - "name": { - "type": "string", - "readOnly": true + "id": { + "type": "integer" }, - "public_code": { - "type": "string", - "readOnly": true + "provider": { + "type": "string" }, - "slug": { - "type": "string", - "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" + "environment": { + "type": "string" }, - "location": { - "type": "string", - "readOnly": true + "makerspace_id": { + "type": "integer" }, - "map_url": { - "type": "string", - "readOnly": true + "active": { + "type": "boolean" }, - "logo_url": { + "created_at": { "type": "string", - "format": "uri", - "nullable": true, - "readOnly": true + "format": "date-time" }, - "cover_image_url": { + "updated_at": { "type": "string", - "format": "uri", - "nullable": true, - "readOnly": true + "format": "date-time" } }, "required": [ - "cover_image_url", - "location", - "logo_url", - "map_url", - "name", - "public_code", - "slug" + "active", + "created_at", + "environment", + "id", + "makerspace_id", + "provider", + "updated_at" ] }, - "PublicMaterial": { + "PushDeviceRegistration": { "type": "object", "properties": { - "id": { - "type": "integer", - "nullable": true + "token": { + "type": "string", + "writeOnly": true, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_:.-]{20,4096}$" }, - "name": { - "type": "string" + "provider": { + "$ref": "#/components/schemas/PushDeviceRegistrationProviderEnum" }, - "colours": { - "type": "array", - "items": { - "type": "string" - } + "environment": { + "$ref": "#/components/schemas/EnvironmentEnum" } }, "required": [ - "colours", - "id", - "name" + "environment", + "provider", + "token" ] }, - "PublicOpeningInterval": { + "PushDeviceRegistrationProviderEnum": { + "enum": [ + "fcm", + "apns" + ], + "type": "string", + "description": "* `fcm` - FCM\n* `apns` - APNs" + }, + "QrCode": { "type": "object", "properties": { - "starts_at": { + "id": { + "type": "integer", + "readOnly": true + }, + "makerspace": { + "type": "integer" + }, + "payload": { + "type": "string", + "readOnly": true + }, + "target_type": { + "$ref": "#/components/schemas/QrCodeTargetTypeEnum" + }, + "target_id": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/Status37fEnum" + } + ], + "readOnly": true, + "default": "active" + }, + "created_at": { "type": "string", "format": "date-time", "readOnly": true }, - "ends_at": { + "updated_at": { "type": "string", "format": "date-time", "readOnly": true + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true } }, "required": [ - "ends_at", - "starts_at" + "created_at", + "id", + "makerspace", + "payload", + "revoked_at", + "status", + "target_id", + "target_type", + "updated_at" ] }, - "PublicOrganization": { + "QrCodeTargetTypeEnum": { + "enum": [ + "box", + "product", + "asset", + "member_card" + ], + "type": "string", + "description": "* `box` - Box\n* `product` - Product\n* `asset` - Asset\n* `member_card` - Member card" + }, + "QrHistoryItem": { "type": "object", "properties": { - "slug": { - "type": "string", - "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" + "id": { + "type": "string" }, - "name": { - "type": "string", - "readOnly": true + "source": { + "type": "string" }, - "description": { - "type": "string", - "readOnly": true + "context": { + "type": "string" }, - "website": { - "type": "string", - "format": "uri", - "readOnly": true + "actor": { + "type": "integer", + "nullable": true }, - "logo_url": { + "created_at": { "type": "string", - "format": "uri", - "nullable": true, - "readOnly": true - }, - "catalogue_links": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "readOnly": true + "format": "date-time" } }, "required": [ - "catalogue_links", - "description", - "logo_url", - "name", - "slug", - "website" + "actor", + "context", + "created_at", + "id", + "source" ] }, - "PublicOrganizationEvent": { + "QrPrintBatch": { "type": "object", "properties": { - "registration_audience": { - "allOf": [ - { - "$ref": "#/components/schemas/EventDefaultRegistrationAudienceEnum" - } - ], - "readOnly": true - }, - "category": { - "type": "object", - "nullable": true, - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "slug": { - "type": "string" - } - }, - "readOnly": true - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "readOnly": true - }, - "ticket_types": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "amount": { - "type": "string" - }, - "audience": { - "type": "string" - }, - "spots_left": { - "type": "integer", - "nullable": true - } - } - }, - "readOnly": true - }, - "promo_codes_enabled": { - "type": "boolean", - "readOnly": true - }, - "ticket_selection_required": { - "type": "boolean", + "id": { + "type": "integer", "readOnly": true }, - "public_token": { - "type": "string", - "format": "uuid", - "readOnly": true + "makerspace": { + "type": "integer" }, "title": { "type": "string", - "readOnly": true + "maxLength": 200 }, - "description": { - "type": "string", - "readOnly": true + "status": { + "$ref": "#/components/schemas/QrPrintBatchStatusEnum" }, - "starts_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "created_by": { + "type": "integer", + "nullable": true }, - "ends_at": { + "created_at": { "type": "string", "format": "date-time", "readOnly": true }, - "location": { + "printed_at": { "type": "string", - "readOnly": true - }, - "location_kind": { - "allOf": [ - { - "$ref": "#/components/schemas/LocationKindEnum" - } - ], - "readOnly": true - }, - "custom_form": { - "readOnly": true, + "format": "date-time", "nullable": true - }, - "capacity": { + } + }, + "required": [ + "created_at", + "id", + "makerspace", + "title" + ] + }, + "QrPrintBatchCreate": { + "type": "object", + "properties": { + "title": { + "type": "string" + } + }, + "required": [ + "title" + ] + }, + "QrPrintBatchDetail": { + "type": "object", + "properties": { + "id": { "type": "integer", - "minimum": 0, "readOnly": true }, - "payment_amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "readOnly": true + "makerspace": { + "type": "integer" }, - "currency": { + "title": { "type": "string", - "readOnly": true + "maxLength": 200 }, - "availability": { - "allOf": [ - { - "$ref": "#/components/schemas/AvailabilityEnum" - } - ], - "readOnly": true + "status": { + "$ref": "#/components/schemas/QrPrintBatchStatusEnum" }, - "registration_requires_approval": { - "type": "boolean", - "readOnly": true + "created_by": { + "type": "integer", + "nullable": true }, - "effective_registration_cutoff_at": { + "created_at": { "type": "string", "format": "date-time", - "nullable": true, - "readOnly": true - }, - "registration_open": { - "type": "boolean", "readOnly": true }, - "image_url": { + "printed_at": { "type": "string", - "format": "uri", - "nullable": true, - "readOnly": true - }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/StatusE90Enum" - } - ], - "readOnly": true + "format": "date-time", + "nullable": true }, - "organizers": { + "items": { "type": "array", "items": { - "$ref": "#/components/schemas/EventOrganizerSummary" - }, - "readOnly": true - }, - "series": { - "type": "object", - "nullable": true, - "properties": { - "public_token": { - "type": "string", - "format": "uuid" - }, - "title": { - "type": "string" - } + "$ref": "#/components/schemas/QrPrintBatchItem" }, "readOnly": true - }, - "host": { - "allOf": [ - { - "$ref": "#/components/schemas/OrganizationEventHost" - } - ], - "readOnly": true } }, "required": [ - "availability", - "capacity", - "category", - "currency", - "custom_form", - "description", - "effective_registration_cutoff_at", - "ends_at", - "host", - "image_url", - "location", - "location_kind", - "organizers", - "payment_amount", - "promo_codes_enabled", - "public_token", - "registration_audience", - "registration_open", - "registration_requires_approval", - "series", - "starts_at", - "status", - "tags", - "ticket_selection_required", - "ticket_types", + "created_at", + "id", + "items", + "makerspace", "title" ] }, - "PublicOrganizationEventList": { + "QrPrintBatchItem": { "type": "object", "properties": { - "count": { - "type": "integer" + "id": { + "type": "integer", + "readOnly": true }, - "next": { + "qr_code": { + "allOf": [ + { + "$ref": "#/components/schemas/QrCode" + } + ], + "readOnly": true + }, + "label_text": { "type": "string", - "nullable": true + "maxLength": 255 }, - "previous": { + "target_type": { "type": "string", - "nullable": true + "maxLength": 20 }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicOrganizationEvent" - } + "target_id": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "sort_order": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 } }, "required": [ - "count", - "next", - "previous", - "results" + "id", + "label_text", + "qr_code", + "target_id", + "target_type" ] }, - "PublicPrintStatusLookupPolicyEnum": { - "enum": [ - "token_only", - "email_unverified" - ], - "type": "string", - "description": "* `token_only` - Token only\n* `email_unverified` - Email unverified" - }, - "PublicPrinterPool": { + "QrPrintBatchItemCreate": { "type": "object", "properties": { - "id": { + "qr_code_id": { "type": "integer" }, - "material": { + "label_text": { "type": "string" }, - "color": { - "type": "string" + "sort_order": { + "type": "integer", + "minimum": 0 } }, "required": [ - "color", - "id", - "material" + "qr_code_id" ] }, - "PublicPrinterQueue": { + "QrPrintBatchItemResult": { "type": "object", "properties": { - "machine_type_id": { - "type": "integer" - }, "id": { "type": "integer" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" } }, "required": [ - "description", - "id", - "machine_type_id", - "name" + "id" ] }, - "PublicPrinterStatus": { + "QrPrintBatchStatusEnum": { + "enum": [ + "draft", + "printed", + "archived" + ], + "type": "string", + "description": "* `draft` - Draft\n* `printed` - Printed\n* `archived` - Archived" + }, + "QrRebindResult": { "type": "object", - "description": "The token endpoint's complete field allowlist. Do not add contact fields.", "properties": { - "public_token": { - "type": "string", - "format": "uuid" - }, - "status": { - "type": "string" - }, - "title": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "accepted_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "started_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "completed_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "estimated_minutes": { - "type": "integer" - }, - "queue_position": { - "type": "integer", - "nullable": true, - "readOnly": true - }, - "queue_approved_ahead": { - "type": "integer", - "nullable": true, - "readOnly": true + "qr": { + "$ref": "#/components/schemas/QrCode" }, - "queue_awaiting_review_ahead": { - "type": "integer", - "nullable": true, - "readOnly": true + "target": { + "type": "object", + "additionalProperties": {} } }, "required": [ - "accepted_at", - "completed_at", - "created_at", - "estimated_minutes", - "public_token", - "queue_approved_ahead", - "queue_awaiting_review_ahead", - "queue_position", - "started_at", - "status", - "title" + "qr", + "target" ] }, - "PublicPrinterSubmit": { + "QrRebindTarget": { "type": "object", "properties": { - "certification_override_reason": { - "type": "string", - "writeOnly": true, - "maxLength": 1000 + "target_type": { + "$ref": "#/components/schemas/QrRebindTargetTargetTypeEnum" }, - "website": { - "type": "string" + "target_id": { + "type": "integer" }, - "queue_id": { + "destination_makerspace_id": { "type": "integer", "nullable": true }, - "title": { - "type": "string", - "maxLength": 200 - }, - "project_brief": { - "type": "string" - }, - "preferred_settings": { - "nullable": true - }, - "material": { - "type": "string", - "maxLength": 100 - }, - "color": { - "type": "string", - "maxLength": 100 - }, - "consumable_pool_id": { + "destination_product_id": { "type": "integer", "nullable": true }, - "estimated_filament_grams": { + "new_name": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,6}(?:\\.\\d{0,2})?$", - "nullable": true - }, - "quantity": { - "type": "integer", - "minimum": 1, - "default": 1 - }, - "source_link": { - "oneOf": [ - { - "type": "string", - "format": "uri", - "maxLength": 200 - }, - { - "type": "string", - "maxLength": 0 - } - ] - }, - "file_ids": { - "type": "array", - "items": { - "type": "integer" - } + "maxLength": 100 } }, "required": [ - "title" + "target_id", + "target_type" ] }, - "PublicPrinterSubmitResponse": { + "QrRebindTargetTargetTypeEnum": { + "enum": [ + "product", + "asset" + ], + "type": "string", + "description": "* `product` - product\n* `asset` - asset" + }, + "QrResolve": { "type": "object", "properties": { - "public_token": { - "type": "string", - "format": "uuid" - }, - "status": { + "payload": { "type": "string" } }, "required": [ - "public_token", - "status" + "payload" ] }, - "PublicPrinterUpload": { + "QrResolveResult": { "type": "object", "properties": { - "queue_id": { - "type": "integer", - "nullable": true - }, - "kind": { - "$ref": "#/components/schemas/PublicPrinterUploadKindEnum" + "qr": { + "$ref": "#/components/schemas/QrCode" }, - "filename": { - "type": "string", - "maxLength": 255 + "target": { + "type": "object", + "additionalProperties": {} }, - "content_type": { - "type": "string", - "maxLength": 128 + "allowed_actions": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ - "filename", - "kind" + "allowed_actions", + "qr", + "target" ] }, - "PublicPrinterUploadKindEnum": { - "enum": [ - "stl", - "screenshot" - ], - "type": "string", - "description": "* `stl` - stl\n* `screenshot` - screenshot" - }, - "PublicProduct": { + "QrScan": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "name": { - "type": "string", - "readOnly": true + "payload": { + "type": "string" }, - "description": { - "type": "string", - "readOnly": true + "context": { + "$ref": "#/components/schemas/ContextEnum" }, - "category_id": { + "request_id": { "type": "integer", - "readOnly": true, - "nullable": true - }, - "category_name": { - "type": "string", - "readOnly": true, "nullable": true + } + }, + "required": [ + "context", + "payload" + ] + }, + "QrScanResult": { + "type": "object", + "properties": { + "qr": { + "$ref": "#/components/schemas/QrCode" }, - "category_slug": { - "type": "string", - "readOnly": true, - "nullable": true - }, - "borrowing_terms": { - "type": "string", - "readOnly": true - }, - "availability": { + "target": { "type": "object", - "nullable": true, - "properties": { - "mode": { - "type": "string", - "enum": [ - "status_only", - "exact_count" - ] - }, - "label": { - "type": "string", - "enum": [ - "Available", - "Limited", - "Unavailable" - ] - }, - "count": { - "type": "integer", - "minimum": 0 - } - }, - "required": [ - "mode" - ], - "readOnly": true + "additionalProperties": {} }, - "image_url": { - "type": "string", - "format": "uri", - "nullable": true, - "readOnly": true + "scan_id": { + "type": "integer" } }, "required": [ - "availability", - "borrowing_terms", - "category_id", - "category_name", - "category_slug", - "description", - "id", - "image_url", - "name" + "qr", + "scan_id", + "target" ] }, - "PublicQueueEstimate": { + "QrScansReport": { "type": "object", "properties": { - "length": { - "type": "integer", - "minimum": 0 + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "eta_minutes": { - "type": "integer", - "minimum": 0, - "nullable": true + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QrScansReportRow" + } } }, "required": [ - "eta_minutes", - "length" + "rows", + "typed_rows" ] }, - "PublicRequestItemStatus": { + "QrScansReportRow": { "type": "object", "properties": { - "product_name": { + "makerspace_id": { + "type": "integer" + }, + "period": { "type": "string", - "readOnly": true + "format": "date" }, - "requested_quantity": { + "context": { + "type": "string" + }, + "target_type": { + "type": "string" + }, + "target_id": { "type": "integer", - "readOnly": true + "nullable": true, + "description": "Member-card identifiers are suppressed." + }, + "hour_of_week": { + "type": "integer", + "maximum": 167, + "minimum": 0 + }, + "count": { + "type": "integer" } }, "required": [ - "product_name", - "requested_quantity" + "context", + "count", + "hour_of_week", + "period", + "target_id", + "target_type" ] }, - "PublicRequestStatus": { + "QuantityChainSummary": { "type": "object", "properties": { - "status": { - "type": "string", - "readOnly": true + "loan_count": { + "type": "integer" }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "direct_loan_count": { + "type": "integer" }, - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicRequestItemStatus" - }, - "readOnly": true + "issued_quantity": { + "type": "integer" + }, + "returned_quantity": { + "type": "integer" + }, + "damaged_quantity": { + "type": "integer" + }, + "missing_quantity": { + "type": "integer" + }, + "active_quantity": { + "type": "integer" } }, "required": [ - "created_at", - "items", - "status" + "active_quantity", + "damaged_quantity", + "direct_loan_count", + "issued_quantity", + "loan_count", + "missing_quantity", + "returned_quantity" ] }, - "PublicSpaceAvailability": { + "RailEnum": { + "enum": [ + "online", + "offline", + "unallocated" + ], + "type": "string", + "description": "* `online` - online\n* `offline` - offline\n* `unallocated` - unallocated" + }, + "RateLimitTierEnum": { + "enum": [ + "public", + "standard", + "trusted" + ], + "type": "string", + "description": "* `public` - Public\n* `standard` - Standard\n* `trusted` - Trusted" + }, + "RazorpayMobilePaymentIntentResponse": { "type": "object", "properties": { - "public_token": { - "type": "string", - "format": "uuid", - "readOnly": true + "vendor": { + "$ref": "#/components/schemas/RazorpayMobilePaymentIntentResponseVendorEnum" }, - "starts_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "payment_id": { + "type": "integer" }, - "ends_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "key_id": { + "type": "string" }, - "timezone": { - "type": "string", - "readOnly": true + "order_id": { + "type": "string" }, - "opening_intervals": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicOpeningInterval" - }, - "readOnly": true, - "description": "Open windows in UTC, accounting for makerspace hours and dated exceptions." + "amount_minor": { + "type": "integer" }, - "availability": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicAvailabilityInterval" - }, - "readOnly": true, - "nullable": true + "currency": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "amount_minor", + "currency", + "description", + "key_id", + "name", + "order_id", + "payment_id", + "vendor" + ] + }, + "RazorpayMobilePaymentIntentResponseVendorEnum": { + "enum": [ + "razorpay" + ], + "type": "string", + "description": "* `razorpay` - razorpay" + }, + "ReadAll": { + "type": "object", + "properties": { + "updated": { + "type": "integer" } }, "required": [ - "availability", - "ends_at", - "opening_intervals", - "public_token", - "starts_at", - "timezone" + "updated" ] }, - "PublicStats": { + "Readiness": { "type": "object", "properties": { - "machines": { - "allOf": [ - { - "$ref": "#/components/schemas/PublicStatsMachines" - } - ], - "readOnly": true, - "nullable": true - }, - "printing": { - "allOf": [ - { - "$ref": "#/components/schemas/PublicStatsPrinting" - } - ], - "readOnly": true, - "nullable": true + "status": { + "type": "string" }, - "hardware": { - "allOf": [ - { - "$ref": "#/components/schemas/PublicStatsHardware" - } - ], - "readOnly": true + "database": { + "type": "string" }, - "current_loans": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicStatsCurrentLoan" - }, - "readOnly": true + "archive_custody": { + "$ref": "#/components/schemas/ArchiveCustodyReadiness" } }, "required": [ - "current_loans", - "hardware", - "machines", - "printing" + "archive_custody", + "database", + "status" ] }, - "PublicStatsBrand": { + "ReadinessItem": { "type": "object", "properties": { - "brand": { - "type": "string", - "readOnly": true + "ready": { + "type": "boolean" }, - "grams": { - "type": "number", - "format": "double", - "readOnly": true + "hint": { + "type": "string" } }, "required": [ - "brand", - "grams" + "hint", + "ready" ] }, - "PublicStatsBusiestPrinter": { + "Reason870Enum": { + "enum": [ + "bounce", + "complaint" + ], + "type": "string", + "description": "* `bounce` - Hard bounce\n* `complaint` - Spam complaint" + }, + "Reauthentication": { "type": "object", "properties": { - "name": { + "password": { "type": "string", - "readOnly": true + "writeOnly": true, + "default": "", + "maxLength": 1024 }, - "model": { + "code": { "type": "string", - "readOnly": true - }, - "hours": { - "type": "number", - "format": "double", - "readOnly": true - }, - "completed": { - "type": "integer", - "readOnly": true - }, - "image_url": { + "writeOnly": true, + "default": "", + "maxLength": 6 + } + } + }, + "ReauthenticationEmailConfirm": { + "type": "object", + "properties": { + "token": { "type": "string", - "format": "uri", - "readOnly": true, - "nullable": true + "writeOnly": true, + "maxLength": 200 } }, "required": [ - "completed", - "hours", - "image_url", - "model", - "name" + "token" ] }, - "PublicStatsCurrentLoan": { + "ReauthenticationEmailConfirmed": { "type": "object", "properties": { - "item_name": { - "type": "string", - "readOnly": true - }, - "holder_name": { - "type": "string", - "readOnly": true - }, - "due": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "since": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "confirmed": { + "type": "boolean" } }, "required": [ - "due", - "holder_name", - "item_name", - "since" + "confirmed" ] }, - "PublicStatsFilamentTrend": { + "ReauthenticationEmailSent": { "type": "object", "properties": { - "period": { - "type": "string", - "readOnly": true - }, - "grams": { - "type": "number", - "format": "double", - "readOnly": true + "sent": { + "type": "boolean" } }, "required": [ - "grams", - "period" + "sent" ] }, - "PublicStatsHardware": { + "ReauthenticationFactor": { "type": "object", "properties": { - "most_popular": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicStatsPopularHardware" - }, - "readOnly": true - }, - "tools_out": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicStatsToolsOut" - }, - "readOnly": true - }, - "library": { - "allOf": [ - { - "$ref": "#/components/schemas/PublicStatsLibrary" - } - ], - "readOnly": true + "token": { + "type": "string", + "writeOnly": true, + "maxLength": 200 }, - "recently_added": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicStatsRecentlyAdded" - }, - "readOnly": true + "code": { + "type": "string", + "writeOnly": true, + "maxLength": 6, + "pattern": "^[0-9]{6}$" } }, "required": [ - "library", - "most_popular", - "recently_added", - "tools_out" + "code", + "token" ] }, - "PublicStatsJobs": { + "ReauthenticationPhoneStarted": { "type": "object", "properties": { - "completed": { - "type": "integer", - "readOnly": true - }, - "status_counts": { - "allOf": [ - { - "$ref": "#/components/schemas/PublicStatsStatusCounts" - } - ], - "readOnly": true + "sent": { + "type": "boolean" }, - "queue": { - "allOf": [ - { - "$ref": "#/components/schemas/PublicStatsQueue" - } - ], - "readOnly": true + "token": { + "type": "string" } }, "required": [ - "completed", - "queue", - "status_counts" + "sent", + "token" ] }, - "PublicStatsLibrary": { + "ReauthenticationResult": { "type": "object", "properties": { - "currently_out_count": { - "type": "integer", - "readOnly": true + "verified": { + "type": "boolean" }, - "library_size": { - "type": "integer", - "readOnly": true + "expires_in": { + "type": "integer" }, - "available_count": { - "type": "integer", - "readOnly": true + "mfa_required": { + "type": "boolean" + }, + "factor_required": { + "$ref": "#/components/schemas/FactorRequiredEnum" + }, + "factor_token": { + "type": "string" } }, "required": [ - "available_count", - "currently_out_count", - "library_size" + "expires_in", + "verified" ] }, - "PublicStatsMachines": { + "ReauthenticationSocialComplete": { "type": "object", "properties": { - "usage_hours": { - "type": "number", - "format": "double", - "readOnly": true + "state": { + "type": "string", + "writeOnly": true, + "maxLength": 200 + }, + "nonce": { + "type": "string", + "writeOnly": true, + "maxLength": 200 + }, + "id_token": { + "type": "string", + "writeOnly": true, + "default": "", + "maxLength": 16384 + }, + "code": { + "type": "string", + "writeOnly": true, + "default": "", + "maxLength": 2048 + }, + "totp": { + "type": "string", + "writeOnly": true, + "default": "", + "maxLength": 6 } }, "required": [ - "usage_hours" + "nonce", + "state" ] }, - "PublicStatsPopularHardware": { + "ReauthenticationSocialStart": { "type": "object", "properties": { - "name": { + "provider": { "type": "string", - "readOnly": true - }, - "times_lent": { - "type": "integer", - "readOnly": true + "maxLength": 64 }, - "total_quantity_lent": { - "type": "integer", - "readOnly": true + "redirect_uri": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "default": "", + "maxLength": 2048 + }, + { + "type": "string", + "maxLength": 0 + } + ] } }, "required": [ - "name", - "times_lent", - "total_quantity_lent" + "provider" ] }, - "PublicStatsPrinter": { + "ReauthenticationSocialStarted": { "type": "object", "properties": { - "name": { - "type": "string", - "readOnly": true - }, - "model": { + "authorization_url": { "type": "string", - "readOnly": true - }, - "jobs": { - "type": "integer", - "readOnly": true + "format": "uri" }, - "hours": { - "type": "number", - "format": "double", - "readOnly": true + "state": { + "type": "string" }, - "grams": { - "type": "number", - "format": "double", - "readOnly": true + "nonce": { + "type": "string" }, - "image_url": { - "type": "string", - "format": "uri", - "readOnly": true, - "nullable": true + "expires_in": { + "type": "integer" } }, "required": [ - "grams", - "hours", - "image_url", - "jobs", - "model", - "name" + "authorization_url", + "expires_in", + "nonce", + "state" ] }, - "PublicStatsPrinting": { + "ReauthenticationStatus": { "type": "object", "properties": { - "hours_all_time": { - "type": "number", - "format": "double", - "readOnly": true + "verified": { + "type": "boolean" }, - "hours_this_month": { - "type": "number", - "format": "double", - "readOnly": true + "password_available": { + "type": "boolean" }, - "busiest_printer": { - "allOf": [ - { - "$ref": "#/components/schemas/PublicStatsBusiestPrinter" - } - ], - "readOnly": true, - "nullable": true + "mfa_enabled": { + "type": "boolean" }, - "per_printer": { + "providers": { "type": "array", "items": { - "$ref": "#/components/schemas/PublicStatsPrinter" - }, - "readOnly": true + "type": "string" + } + } + }, + "required": [ + "mfa_enabled", + "password_available", + "providers", + "verified" + ] + }, + "ReceiptEnvelope": { + "type": "object", + "properties": { + "payload": { + "type": "object", + "additionalProperties": {} }, - "grams_all_time": { - "type": "number", - "format": "double", - "readOnly": true + "signer_fingerprint": { + "type": "string", + "maxLength": 64 }, - "by_brand": { + "signature": { + "type": "string", + "maxLength": 88 + } + }, + "required": [ + "payload", + "signature", + "signer_fingerprint" + ] + }, + "RecentlyAddedReport": { + "type": "object", + "properties": { + "rows": { "type": "array", "items": { - "$ref": "#/components/schemas/PublicStatsBrand" - }, - "readOnly": true - }, - "jobs": { - "allOf": [ - { - "$ref": "#/components/schemas/PublicStatsJobs" - } - ], - "readOnly": true + "type": "array", + "items": {} + } }, - "filament_trend": { + "typed_rows": { "type": "array", "items": { - "$ref": "#/components/schemas/PublicStatsFilamentTrend" - }, - "readOnly": true + "$ref": "#/components/schemas/RecentlyAddedReportRow" + } } }, "required": [ - "busiest_printer", - "by_brand", - "filament_trend", - "grams_all_time", - "hours_all_time", - "hours_this_month", - "jobs", - "per_printer" + "rows", + "typed_rows" ] }, - "PublicStatsQueue": { + "RecentlyAddedReportRow": { "type": "object", "properties": { - "pending": { - "type": "integer", - "readOnly": true + "makerspace_id": { + "type": "integer" }, - "accepted": { - "type": "integer", - "readOnly": true + "product_name": { + "type": "string" }, - "printing": { - "type": "integer", - "readOnly": true + "created_at": { + "type": "string", + "format": "date-time" + }, + "total_quantity": { + "type": "integer" } }, "required": [ - "accepted", - "pending", - "printing" + "created_at", + "product_name", + "total_quantity" ] }, - "PublicStatsRecentlyAdded": { + "RecipeEnum": { + "enum": [ + "free_open", + "free_vouch", + "paid_approval", + "paid_invite_only", + "closed" + ], + "type": "string", + "description": "* `free_open` - free_open\n* `free_vouch` - free_vouch\n* `paid_approval` - paid_approval\n* `paid_invite_only` - paid_invite_only\n* `closed` - closed" + }, + "RecipientActionsEnum": { + "enum": [ + "accept_request", + "assign_box", + "collect_service_request", + "edit_inventory", + "issue_direct_loan", + "issue_request", + "manage_bookings", + "manage_devices", + "manage_events", + "manage_machines", + "manage_makerspace", + "manage_member_cards", + "manage_members", + "manage_printing", + "manage_qr", + "record_payment", + "reject_request", + "return_request", + "scan_member_cards", + "upload_evidence", + "view_audit", + "view_finance", + "view_inventory" + ], + "type": "string", + "description": "* `accept_request` - accept_request\n* `assign_box` - assign_box\n* `collect_service_request` - collect_service_request\n* `edit_inventory` - edit_inventory\n* `issue_direct_loan` - issue_direct_loan\n* `issue_request` - issue_request\n* `manage_bookings` - manage_bookings\n* `manage_devices` - manage_devices\n* `manage_events` - manage_events\n* `manage_machines` - manage_machines\n* `manage_makerspace` - manage_makerspace\n* `manage_member_cards` - manage_member_cards\n* `manage_members` - manage_members\n* `manage_printing` - manage_printing\n* `manage_qr` - manage_qr\n* `record_payment` - record_payment\n* `reject_request` - reject_request\n* `return_request` - return_request\n* `scan_member_cards` - scan_member_cards\n* `upload_evidence` - upload_evidence\n* `view_audit` - view_audit\n* `view_finance` - view_finance\n* `view_inventory` - view_inventory" + }, + "RecipientFeature": { "type": "object", "properties": { - "name": { - "type": "string", - "readOnly": true + "key": { + "type": "string" }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "events": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ - "created_at", - "name" + "events", + "key" ] }, - "PublicStatsStatusCounts": { + "RecipientMember": { "type": "object", "properties": { - "pending": { - "type": "integer", - "readOnly": true - }, - "accepted": { - "type": "integer", - "readOnly": true - }, - "printing": { - "type": "integer", - "readOnly": true - }, - "completed": { - "type": "integer", - "readOnly": true - }, - "collected": { - "type": "integer", - "readOnly": true + "id": { + "type": "integer" }, - "failed": { - "type": "integer", - "readOnly": true + "username": { + "type": "string" }, - "rejected": { - "type": "integer", - "readOnly": true + "email": { + "oneOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "string", + "maxLength": 0 + } + ] } }, "required": [ - "accepted", - "collected", - "completed", - "failed", - "pending", - "printing", - "rejected" + "email", + "id", + "username" ] }, - "PublicStatsToolsOut": { + "RecipientRole": { "type": "object", "properties": { + "id": { + "type": "integer" + }, "name": { - "type": "string", - "readOnly": true + "type": "string" }, - "quantity_out": { - "type": "integer", - "readOnly": true + "slug": { + "type": "string" } }, "required": [ + "id", "name", - "quantity_out" + "slug" ] }, - "PublicToolCheckout": { + "RecipientRule": { "type": "object", "properties": { - "kit_checklist": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KitCount" - }, - "maxItems": 3550 + "kind": { + "$ref": "#/components/schemas/KindE56Enum" }, - "payload": { - "type": "string", - "maxLength": 64 + "role_id": { + "type": "integer", + "nullable": true }, - "evidence_id": { - "type": "integer" + "user_id": { + "type": "integer", + "nullable": true }, - "remark": { - "type": "string" + "scope": { + "$ref": "#/components/schemas/RecipientScope" } }, "required": [ - "evidence_id", - "payload" + "kind" ] }, - "PublicToolEvidenceUrlRequest": { + "RecipientRuleOutput": { "type": "object", "properties": { - "evidence_type": { - "$ref": "#/components/schemas/EvidenceType740Enum" + "kind": { + "$ref": "#/components/schemas/KindE56Enum" }, - "content_type": { - "type": "string" + "role_id": { + "type": "integer", + "nullable": true }, - "size_bytes": { + "user_id": { + "type": "integer", + "nullable": true + }, + "scope": { + "$ref": "#/components/schemas/RecipientScope" + }, + "id": { "type": "integer", - "minimum": 0, "nullable": true + }, + "feature": { + "type": "string" + }, + "event": { + "type": "string" } }, "required": [ - "content_type", - "evidence_type" + "event", + "feature", + "id", + "kind" ] }, - "PublicToolLoan": { + "RecipientRulesPut": { "type": "object", "properties": { - "has_kit_checklist": { - "type": "boolean", - "readOnly": true - }, - "public_token": { + "feature": { "type": "string", - "format": "uuid", - "readOnly": true + "maxLength": 32 }, - "status": { + "event": { "type": "string", - "readOnly": true + "maxLength": 64 }, - "items": { + "rules": { "type": "array", "items": { - "$ref": "#/components/schemas/PublicToolLoanItem" - }, - "readOnly": true + "$ref": "#/components/schemas/RecipientRule" + } } }, "required": [ - "has_kit_checklist", - "items", - "public_token", - "status" + "event", + "feature", + "rules" ] }, - "PublicToolLoanItem": { + "RecipientRulesResponse": { "type": "object", "properties": { - "product_name": { - "type": "string" + "delegated": { + "type": "boolean" }, - "quantity": { - "type": "integer" - } - }, - "required": [ - "product_name", - "quantity" - ] - }, - "PublicToolScan": { - "type": "object", - "properties": { - "kit_checklist": { + "features": { "type": "array", "items": { - "$ref": "#/components/schemas/KitCount" - }, - "maxItems": 3550 + "$ref": "#/components/schemas/RecipientFeature" + } }, - "payload": { - "type": "string", - "maxLength": 64 + "roles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecipientRole" + } }, - "evidence_id": { - "type": "integer" + "members": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecipientMember" + } }, - "remark": { - "type": "string" + "rules": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecipientRuleOutput" + } }, - "report_problem": { - "type": "boolean", - "default": false + "managed_policy_markers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManagedPolicyMarker" + } }, - "problem_note": { - "type": "string", - "default": "" + "scope_options": { + "$ref": "#/components/schemas/RecipientScopeOptions" } }, "required": [ - "evidence_id", - "payload", - "remark" + "delegated", + "features", + "managed_policy_markers", + "members", + "roles", + "rules", + "scope_options" ] }, - "PublicTypeIntake": { + "RecipientScope": { "type": "object", "properties": { - "materials": { + "machine_type_ids": { "type": "array", "items": { - "$ref": "#/components/schemas/PublicMaterial" + "type": "integer" } }, - "intake_schema": {} - }, - "required": [ - "intake_schema", - "materials" - ] - }, - "PurposeEnum": { - "enum": [ - "enroll", - "rotate", - "reactivate" - ], - "type": "string", - "description": "* `enroll` - Enroll\n* `rotate` - Rotate\n* `reactivate` - Reactivate" + "machine_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "category_ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } }, - "PushDevice": { + "RecipientScopeOption": { "type": "object", "properties": { "id": { "type": "integer" }, - "provider": { - "type": "string" - }, - "environment": { + "name": { "type": "string" - }, - "makerspace_id": { - "type": "integer" - }, - "active": { - "type": "boolean" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" } }, "required": [ - "active", - "created_at", - "environment", "id", - "makerspace_id", - "provider", - "updated_at" + "name" ] }, - "PushDeviceRegistration": { + "RecipientScopeOptions": { "type": "object", "properties": { - "token": { - "type": "string", - "writeOnly": true, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_:.-]{20,4096}$" + "machine_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecipientScopeOption" + } }, - "provider": { - "$ref": "#/components/schemas/PushDeviceRegistrationProviderEnum" + "machines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecipientScopeOption" + } }, - "environment": { - "$ref": "#/components/schemas/EnvironmentEnum" + "categories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecipientScopeOption" + } } }, "required": [ - "environment", - "provider", - "token" + "categories", + "machine_types", + "machines" ] }, - "PushDeviceRegistrationProviderEnum": { - "enum": [ - "fcm", - "apns" - ], - "type": "string", - "description": "* `fcm` - FCM\n* `apns` - APNs" + "RecoveryAcknowledge": { + "type": "object", + "properties": { + "acknowledgement": { + "type": "string" + } + }, + "required": [ + "acknowledgement" + ] }, - "QrCode": { + "RecoveryState": { "type": "object", "properties": { - "id": { - "type": "integer", + "mode": { + "allOf": [ + { + "$ref": "#/components/schemas/RecoveryStateModeEnum" + } + ], "readOnly": true }, - "makerspace": { - "type": "integer" - }, - "payload": { + "auth_generation": { "type": "string", + "format": "uuid", "readOnly": true }, - "target_type": { - "$ref": "#/components/schemas/QrCodeTargetTypeEnum" + "active_restore": { + "type": "string", + "format": "uuid", + "readOnly": true, + "nullable": true }, - "target_id": { + "recovery_principal": { "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/Status37fEnum" - } - ], "readOnly": true, - "default": "active" + "nullable": true }, - "created_at": { + "quarantine_reason": { "type": "string", - "format": "date-time", "readOnly": true }, - "updated_at": { + "quarantined_at": { "type": "string", "format": "date-time", - "readOnly": true + "readOnly": true, + "nullable": true }, - "revoked_at": { + "acknowledged_at": { "type": "string", "format": "date-time", "readOnly": true, "nullable": true + }, + "acknowledged_by": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "acknowledgement": { + "type": "string", + "readOnly": true + }, + "residual_risk": { + "type": "string", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true } }, "required": [ - "created_at", - "id", - "makerspace", - "payload", - "revoked_at", - "status", - "target_id", - "target_type", + "acknowledged_at", + "acknowledged_by", + "acknowledgement", + "active_restore", + "auth_generation", + "mode", + "quarantine_reason", + "quarantined_at", + "recovery_principal", + "residual_risk", "updated_at" ] }, - "QrCodeTargetTypeEnum": { + "RecoveryStateModeEnum": { "enum": [ - "box", - "product", - "asset", - "member_card" + "normal", + "target_import", + "quiesced", + "quarantined" ], "type": "string", - "description": "* `box` - Box\n* `product` - Product\n* `asset` - Asset\n* `member_card` - Member card" + "description": "* `normal` - Normal\n* `target_import` - Target import\n* `quiesced` - Quiesced\n* `quarantined` - Quarantined" }, - "QrHistoryItem": { + "RecoveryUnavailable": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "source": { + "detail": { "type": "string" }, - "context": { + "code": { "type": "string" - }, - "actor": { - "type": "integer", - "nullable": true - }, - "created_at": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "actor", - "context", - "created_at", - "id", - "source" - ] - }, - "QrPrintBatch": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "makerspace": { - "type": "integer" - }, - "title": { - "type": "string", - "maxLength": 200 - }, - "status": { - "$ref": "#/components/schemas/QrPrintBatchStatusEnum" - }, - "created_by": { - "type": "integer", - "nullable": true - }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "printed_at": { - "type": "string", - "format": "date-time", - "nullable": true } }, "required": [ - "created_at", - "id", - "makerspace", - "title" + "code", + "detail" ] }, - "QrPrintBatchCreate": { + "RecurrenceResult": { "type": "object", "properties": { - "title": { - "type": "string" + "occurrences": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OccurrenceResult" + } } }, "required": [ - "title" + "occurrences" ] }, - "QrPrintBatchDetail": { + "RecurringBookingInput": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "makerspace": { - "type": "integer" - }, - "title": { + "certification_override_reason": { "type": "string", - "maxLength": 200 - }, - "status": { - "$ref": "#/components/schemas/QrPrintBatchStatusEnum" - }, - "created_by": { - "type": "integer", - "nullable": true + "description": "Requires existing authority over the linked machine type; audited.", + "maxLength": 1000 }, - "created_at": { + "starts_at": { "type": "string", "format": "date-time", - "readOnly": true + "writeOnly": true }, - "printed_at": { + "ends_at": { "type": "string", "format": "date-time", + "writeOnly": true + }, + "custom_answers": { + "writeOnly": true, "nullable": true }, - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QrPrintBatchItem" - }, - "readOnly": true - } - }, - "required": [ - "created_at", - "id", - "items", - "makerspace", - "title" - ] - }, - "QrPrintBatchItem": { - "type": "object", - "properties": { - "id": { + "member_id": { "type": "integer", - "readOnly": true - }, - "qr_code": { - "allOf": [ - { - "$ref": "#/components/schemas/QrCode" - } - ], - "readOnly": true - }, - "label_text": { - "type": "string", - "maxLength": 255 + "maximum": 9223372036854775807, + "minimum": 1, + "format": "int64" }, - "target_type": { + "note": { "type": "string", - "maxLength": 20 + "maxLength": 5000 }, - "target_id": { + "count": { "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "maximum": 52, + "minimum": 2 }, - "sort_order": { + "interval_weeks": { "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "maximum": 12, + "minimum": 1, + "default": 1 } }, "required": [ - "id", - "label_text", - "qr_code", - "target_id", - "target_type" + "count", + "ends_at", + "member_id", + "starts_at" ] }, - "QrPrintBatchItemCreate": { + "ReferralCreate": { "type": "object", "properties": { - "qr_code_id": { - "type": "integer" - }, - "label_text": { - "type": "string" - }, - "sort_order": { - "type": "integer", - "minimum": 0 + "invite_email": { + "type": "string", + "format": "email", + "maxLength": 254 } }, "required": [ - "qr_code_id" + "invite_email" ] }, - "QrPrintBatchItemResult": { + "ReferralOutcome": { "type": "object", "properties": { - "id": { - "type": "integer" + "state": { + "type": "string" } }, "required": [ - "id" + "state" ] }, - "QrPrintBatchStatusEnum": { - "enum": [ - "draft", - "printed", - "archived" - ], - "type": "string", - "description": "* `draft` - Draft\n* `printed` - Printed\n* `archived` - Archived" - }, - "QrRebindResult": { + "RefreshResponse": { "type": "object", "properties": { - "qr": { - "$ref": "#/components/schemas/QrCode" - }, - "target": { - "type": "object", - "additionalProperties": {} + "access": { + "type": "string" } }, "required": [ - "qr", - "target" + "access" ] }, - "QrRebindTarget": { + "Refund": { "type": "object", "properties": { - "target_type": { - "$ref": "#/components/schemas/QrRebindTargetTargetTypeEnum" + "id": { + "type": "integer", + "readOnly": true }, - "target_id": { - "type": "integer" + "amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true }, - "destination_makerspace_id": { - "type": "integer", + "currency": { + "type": "string", + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/RefundStatusEnum" + } + ], + "readOnly": true + }, + "provider": { + "allOf": [ + { + "$ref": "#/components/schemas/RefundProviderEnum" + } + ], + "readOnly": true + }, + "reason": { + "type": "string", + "readOnly": true + }, + "method": { + "allOf": [ + { + "$ref": "#/components/schemas/Method2edEnum" + } + ], + "readOnly": true + }, + "reference": { + "type": "string", + "readOnly": true + }, + "refunded_at": { + "type": "string", + "format": "date-time", + "readOnly": true, "nullable": true }, - "destination_product_id": { + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "settled_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "amends": { "type": "integer", + "readOnly": true, "nullable": true }, - "new_name": { - "type": "string", - "maxLength": 100 + "corrected": { + "type": "boolean", + "readOnly": true } }, "required": [ - "target_id", - "target_type" + "amends", + "amount", + "corrected", + "created_at", + "currency", + "id", + "method", + "provider", + "reason", + "reference", + "refunded_at", + "settled_at", + "status" ] }, - "QrRebindTargetTargetTypeEnum": { + "RefundProviderEnum": { "enum": [ - "product", - "asset" + "stripe", + "razorpay", + "manual" ], "type": "string", - "description": "* `product` - product\n* `asset` - asset" + "description": "* `stripe` - Stripe\n* `razorpay` - Razorpay\n* `manual` - Manual" }, - "QrResolve": { - "type": "object", - "properties": { - "payload": { - "type": "string" - } - }, - "required": [ - "payload" - ] + "RefundStatusEnum": { + "enum": [ + "pending", + "succeeded", + "failed" + ], + "type": "string", + "description": "* `pending` - Pending\n* `succeeded` - Succeeded\n* `failed` - Failed" }, - "QrResolveResult": { + "RegistrationReadiness": { "type": "object", "properties": { - "qr": { - "$ref": "#/components/schemas/QrCode" + "hint": { + "type": "string" }, - "target": { + "production": { + "type": "boolean" + }, + "login_ready": { + "type": "boolean" + }, + "login": { "type": "object", - "additionalProperties": {} + "additionalProperties": { + "$ref": "#/components/schemas/ReadinessItem" + } }, - "allowed_actions": { - "type": "array", - "items": { - "type": "string" + "optional": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ReadinessItem" } } }, "required": [ - "allowed_actions", - "qr", - "target" + "hint", + "login", + "login_ready", + "optional", + "production" ] }, - "QrScan": { + "Rehydrate": { "type": "object", "properties": { - "payload": { - "type": "string" - }, - "context": { - "$ref": "#/components/schemas/ContextEnum" - }, - "request_id": { - "type": "integer", - "nullable": true + "destination": { + "type": "integer" } }, "required": [ - "context", - "payload" + "destination" ] }, - "QrScanResult": { + "RejectRequest": { "type": "object", "properties": { - "qr": { - "$ref": "#/components/schemas/QrCode" - }, - "target": { - "type": "object", - "additionalProperties": {} - }, - "scan_id": { - "type": "integer" + "reason": { + "type": "string" } }, "required": [ - "qr", - "scan_id", - "target" + "reason" ] }, - "QrScansReport": { + "RejoinDuesRuleEnum": { + "enum": [ + "once", + "each_join" + ], + "type": "string", + "description": "* `once` - Joining dues once\n* `each_join` - Joining dues on each join" + }, + "RelationshipEnum": { + "enum": [ + "owner", + "manager", + "affiliate" + ], + "type": "string", + "description": "* `owner` - Owner\n* `manager` - Manager\n* `affiliate` - Affiliate" + }, + "ReorderDraft": { "type": "object", "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "typed_rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QrScansReportRow" - } + "product_id": { + "type": "integer", + "minimum": 1 } }, "required": [ - "rows", - "typed_rows" + "product_id" ] }, - "QrScansReportRow": { + "ReorderSuggestion": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" + "id": { + "type": "integer", + "readOnly": true }, - "context": { - "type": "string" + "name": { + "type": "string", + "readOnly": true }, - "count": { - "type": "integer" - } - }, - "required": [ - "context", - "count" - ] - }, - "QuantityChainSummary": { - "type": "object", - "properties": { - "loan_count": { - "type": "integer" + "manufacturer": { + "type": "string", + "readOnly": true }, - "direct_loan_count": { - "type": "integer" + "model": { + "type": "string", + "readOnly": true }, - "issued_quantity": { - "type": "integer" + "sku": { + "type": "string", + "readOnly": true }, - "returned_quantity": { - "type": "integer" + "barcode": { + "type": "string", + "readOnly": true }, - "damaged_quantity": { - "type": "integer" + "supplier": { + "type": "string", + "readOnly": true }, - "missing_quantity": { - "type": "integer" + "available_quantity": { + "type": "integer", + "readOnly": true }, - "active_quantity": { - "type": "integer" + "reorder_point": { + "type": "integer", + "readOnly": true + }, + "reorder_quantity": { + "type": "integer", + "readOnly": true } }, "required": [ - "active_quantity", - "damaged_quantity", - "direct_loan_count", - "issued_quantity", - "loan_count", - "missing_quantity", - "returned_quantity" + "available_quantity", + "barcode", + "id", + "manufacturer", + "model", + "name", + "reorder_point", + "reorder_quantity", + "sku", + "supplier" ] }, - "RateLimitTierEnum": { - "enum": [ - "public", - "standard", - "trusted" - ], - "type": "string", - "description": "* `public` - Public\n* `standard` - Standard\n* `trusted` - Trusted" - }, - "ReadAll": { + "ReplayDirectHandout": { "type": "object", "properties": { - "updated": { + "kit_checklist": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KitCount" + }, + "maxItems": 3550 + }, + "borrower_id": { "type": "integer" - } - }, - "required": [ - "updated" - ] - }, - "Readiness": { - "type": "object", - "properties": { - "status": { - "type": "string" }, - "database": { + "remark": { "type": "string" }, - "archive_custody": { - "$ref": "#/components/schemas/ArchiveCustodyReadiness" - } - }, - "required": [ - "archive_custody", - "database", - "status" - ] - }, - "Reason870Enum": { - "enum": [ - "bounce", - "complaint" - ], - "type": "string", - "description": "* `bounce` - Hard bounce\n* `complaint` - Spam complaint" - }, - "ReceiptEnvelope": { - "type": "object", - "properties": { - "payload": { - "type": "object", - "additionalProperties": {} + "container_id": { + "type": "integer", + "nullable": true }, - "signer_fingerprint": { - "type": "string", - "maxLength": 64 + "qr_payloads": { + "type": "array", + "items": { + "type": "string", + "maxLength": 64 + }, + "maxItems": 50 }, - "signature": { - "type": "string", - "maxLength": 88 + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DirectLoanItem" + } } }, "required": [ - "payload", - "signature", - "signer_fingerprint" + "borrower_id" ] }, - "RecentlyAddedReport": { + "ReplayDirectReturn": { "type": "object", "properties": { - "rows": { + "loan_id": { + "type": "integer", + "minimum": 1 + }, + "kit_checklist": { "type": "array", "items": { - "type": "array", - "items": {} - } + "$ref": "#/components/schemas/KitCount" + }, + "maxItems": 3550 }, - "typed_rows": { + "notes": { + "type": "string" + }, + "qr_payload": { + "type": "string", + "maxLength": 64 + }, + "resolutions": { "type": "array", "items": { - "$ref": "#/components/schemas/RecentlyAddedReportRow" + "$ref": "#/components/schemas/ReturnItemResolution" } } }, "required": [ - "rows", - "typed_rows" + "notes" ] }, - "RecentlyAddedReportRow": { + "ReplayDomainPayload": { + "oneOf": [ + { + "$ref": "#/components/schemas/KioskAssignBox" + }, + { + "$ref": "#/components/schemas/ReplayReviewedIssue" + }, + { + "$ref": "#/components/schemas/ReplayReviewedReturn" + }, + { + "$ref": "#/components/schemas/ReplayDirectHandout" + }, + { + "$ref": "#/components/schemas/ReplayDirectReturn" + } + ] + }, + "ReplayEnvelope": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" + "client_command_id": { + "type": "string", + "format": "uuid" }, - "product_name": { - "type": "string" + "kind": { + "$ref": "#/components/schemas/ReplayEnvelopeKindEnum" }, - "created_at": { + "version": { + "type": "integer", + "minimum": 1 + }, + "aggregate_id": { + "type": "string", + "format": "uuid" + }, + "sequence": { + "type": "integer", + "maximum": 10000, + "minimum": 1 + }, + "predecessor_client_id": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "client_created_at": { "type": "string", "format": "date-time" }, - "total_quantity": { - "type": "integer" + "payload": { + "$ref": "#/components/schemas/ReplayDomainPayload" + }, + "evidence": { + "allOf": [ + { + "$ref": "#/components/schemas/KioskEvidencePrepare" + } + ], + "nullable": true } }, "required": [ - "created_at", - "product_name", - "total_quantity" + "aggregate_id", + "client_command_id", + "client_created_at", + "kind", + "payload", + "sequence", + "version" ] }, - "RecipeEnum": { - "enum": [ - "free_open", - "free_vouch", - "paid_approval", - "paid_invite_only", - "closed" - ], - "type": "string", - "description": "* `free_open` - free_open\n* `free_vouch` - free_vouch\n* `paid_approval` - paid_approval\n* `paid_invite_only` - paid_invite_only\n* `closed` - closed" - }, - "RecipientActionsEnum": { + "ReplayEnvelopeKindEnum": { "enum": [ - "accept_request", "assign_box", - "collect_service_request", - "edit_inventory", - "issue_direct_loan", - "issue_request", - "manage_bookings", - "manage_devices", - "manage_events", - "manage_machines", - "manage_makerspace", - "manage_member_cards", - "manage_members", - "manage_printing", - "manage_qr", - "record_payment", - "reject_request", - "return_request", - "scan_member_cards", - "upload_evidence", - "view_audit", - "view_finance", - "view_inventory" + "reviewed_issue", + "reviewed_return", + "direct_handout", + "direct_return" ], "type": "string", - "description": "* `accept_request` - accept_request\n* `assign_box` - assign_box\n* `collect_service_request` - collect_service_request\n* `edit_inventory` - edit_inventory\n* `issue_direct_loan` - issue_direct_loan\n* `issue_request` - issue_request\n* `manage_bookings` - manage_bookings\n* `manage_devices` - manage_devices\n* `manage_events` - manage_events\n* `manage_machines` - manage_machines\n* `manage_makerspace` - manage_makerspace\n* `manage_member_cards` - manage_member_cards\n* `manage_members` - manage_members\n* `manage_printing` - manage_printing\n* `manage_qr` - manage_qr\n* `record_payment` - record_payment\n* `reject_request` - reject_request\n* `return_request` - return_request\n* `scan_member_cards` - scan_member_cards\n* `upload_evidence` - upload_evidence\n* `view_audit` - view_audit\n* `view_finance` - view_finance\n* `view_inventory` - view_inventory" - }, - "RecipientFeature": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "events": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "events", - "key" - ] + "description": "* `assign_box` - assign_box\n* `reviewed_issue` - reviewed_issue\n* `reviewed_return` - reviewed_return\n* `direct_handout` - direct_handout\n* `direct_return` - direct_return" }, - "RecipientMember": { + "ReplayRecovery": { "type": "object", "properties": { "id": { - "type": "integer" + "type": "string", + "format": "uuid", + "readOnly": true }, - "username": { + "makerspace_id": { + "type": "integer", + "readOnly": true + }, + "station_id": { + "type": "integer", + "readOnly": true + }, + "station_name": { "type": "string" }, - "email": { + "station_kind": { + "type": "string" + }, + "operator_display": { + "type": "string", + "readOnly": true + }, + "operation_kind": { + "type": "string", + "maxLength": 24 + }, + "operation_version": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "aggregate_id": { + "type": "string", + "format": "uuid" + }, + "sequence": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "state": { + "$ref": "#/components/schemas/ReplayRecoveryStateEnum" + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "client_created_at": { + "type": "string", + "format": "date-time" + }, + "completed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "retained_until": { + "type": "string", + "readOnly": true + }, + "failure_code": { + "type": "string", + "nullable": true, + "maxLength": 64 + }, + "failure_detail": { + "type": "string", + "nullable": true, + "maxLength": 200 + }, + "subject_request_id": { + "type": "integer", + "nullable": true + }, + "subject_loan_id": { + "type": "integer", + "nullable": true + }, + "recovery_request_id": { + "type": "integer", + "nullable": true, + "readOnly": true + }, + "recovery_loan_id": { + "type": "integer", + "nullable": true, + "readOnly": true + }, + "result_request_id": { + "type": "integer", + "nullable": true + }, + "result_loan_id": { + "type": "integer", + "nullable": true + }, + "result_return_event_id": { + "type": "integer", + "nullable": true + }, + "predecessor_id": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "predecessor_state": { + "type": "string", + "nullable": true + }, + "evidence_id": { + "type": "integer", + "nullable": true + }, + "can_view_evidence": { + "type": "boolean", + "readOnly": true + }, + "resolution_code": { + "nullable": true, "oneOf": [ { - "type": "string", - "format": "email" + "$ref": "#/components/schemas/ResolutionCodeEnum" }, { - "type": "string", - "maxLength": 0 + "$ref": "#/components/schemas/BlankEnum" + }, + { + "$ref": "#/components/schemas/NullEnum" } ] + }, + "resolved_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resolved_by_display": { + "type": "string", + "nullable": true, + "readOnly": true + }, + "replacement_command_id": { + "type": "string", + "format": "uuid", + "nullable": true } }, "required": [ - "email", + "aggregate_id", + "can_view_evidence", + "client_created_at", + "created_at", + "evidence_id", + "expires_at", "id", - "username" + "makerspace_id", + "operation_kind", + "operation_version", + "operator_display", + "predecessor_id", + "recovery_loan_id", + "recovery_request_id", + "replacement_command_id", + "resolved_by_display", + "result_loan_id", + "result_request_id", + "result_return_event_id", + "retained_until", + "sequence", + "station_id", + "station_kind", + "station_name", + "subject_loan_id", + "subject_request_id" ] }, - "RecipientRole": { + "ReplayRecoveryStateEnum": { + "enum": [ + "prepared", + "claimed", + "succeeded", + "failed" + ], + "type": "string", + "description": "* `prepared` - Prepared\n* `claimed` - Claimed\n* `succeeded` - Succeeded\n* `failed` - Failed" + }, + "ReplayResolution": { "type": "object", "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" + "resolution_code": { + "$ref": "#/components/schemas/ResolutionCodeEnum" }, - "slug": { - "type": "string" + "replacement_command_id": { + "type": "string", + "format": "uuid", + "nullable": true } }, "required": [ - "id", - "name", - "slug" + "resolution_code" ] }, - "RecipientRule": { + "ReplayResult": { "type": "object", "properties": { + "replay_id": { + "type": "string", + "format": "uuid" + }, + "client_command_id": { + "type": "string", + "format": "uuid" + }, + "state": { + "$ref": "#/components/schemas/ReplayResultStateEnum" + }, "kind": { - "$ref": "#/components/schemas/KindE56Enum" + "type": "string" }, - "role_id": { + "expires_at": { + "type": "string", + "format": "date-time" + }, + "evidence_id": { "type": "integer", "nullable": true }, - "user_id": { + "result_request_id": { "type": "integer", "nullable": true }, - "scope": { - "$ref": "#/components/schemas/RecipientScope" + "result_loan_id": { + "type": "integer", + "nullable": true + }, + "result_return_event_id": { + "type": "integer", + "nullable": true + }, + "failure_code": { + "type": "string", + "nullable": true + }, + "failure_detail": { + "type": "string", + "nullable": true + }, + "upload": { + "allOf": [ + { + "$ref": "#/components/schemas/EvidenceUrlResponse" + } + ], + "nullable": true } }, "required": [ - "kind" + "client_command_id", + "evidence_id", + "expires_at", + "failure_code", + "failure_detail", + "kind", + "replay_id", + "result_loan_id", + "result_request_id", + "result_return_event_id", + "state" ] }, - "RecipientRuleOutput": { + "ReplayResultStateEnum": { + "enum": [ + "prepared", + "claimed", + "succeeded", + "failed" + ], + "type": "string", + "description": "* `prepared` - prepared\n* `claimed` - claimed\n* `succeeded` - succeeded\n* `failed` - failed" + }, + "ReplayReviewedIssue": { "type": "object", "properties": { - "kind": { - "$ref": "#/components/schemas/KindE56Enum" - }, - "role_id": { + "request_id": { "type": "integer", - "nullable": true + "minimum": 1 }, - "user_id": { - "type": "integer", - "nullable": true + "kit_checklist": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KitCount" + }, + "maxItems": 3550 }, - "scope": { - "$ref": "#/components/schemas/RecipientScope" + "remark": { + "type": "string", + "default": "" }, - "id": { + "asset_qr_payloads": { + "type": "array", + "items": { + "type": "string" + } + }, + "rejects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssueReject" + } + } + }, + "required": [ + "request_id" + ] + }, + "ReplayReviewedReturn": { + "type": "object", + "properties": { + "request_id": { "type": "integer", - "nullable": true + "minimum": 1 }, - "feature": { + "kit_checklist": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KitCount" + }, + "maxItems": 3550 + }, + "box_code": { "type": "string" }, - "event": { + "remark": { "type": "string" + }, + "resolutions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReturnItemResolution" + } } }, "required": [ - "event", - "feature", - "id", - "kind" + "box_code", + "remark", + "request_id", + "resolutions" ] }, - "RecipientRulesPut": { + "ReportCatalog": { "type": "object", "properties": { - "feature": { - "type": "string", - "maxLength": 32 - }, - "event": { - "type": "string", - "maxLength": 64 - }, - "rules": { + "results": { "type": "array", "items": { - "$ref": "#/components/schemas/RecipientRule" + "$ref": "#/components/schemas/ReportCatalogItem" } } }, "required": [ - "event", - "feature", - "rules" + "results" ] }, - "RecipientRulesResponse": { + "ReportCatalogItem": { "type": "object", "properties": { - "delegated": { - "type": "boolean" + "key": { + "type": "string" }, - "features": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RecipientFeature" - } + "title": { + "type": "string" }, - "roles": { + "fields": { "type": "array", "items": { - "$ref": "#/components/schemas/RecipientRole" + "type": "string" } }, - "members": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RecipientMember" - } + "exportable": { + "type": "boolean" }, - "rules": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RecipientRuleOutput" - } + "summary": { + "type": "boolean" }, - "managed_policy_markers": { + "required_modules": { "type": "array", "items": { - "$ref": "#/components/schemas/ManagedPolicyMarker" + "type": "string" } }, - "scope_options": { - "$ref": "#/components/schemas/RecipientScopeOptions" - } - }, - "required": [ - "delegated", - "features", - "managed_policy_markers", - "members", - "roles", - "rules", - "scope_options" - ] - }, - "RecipientScope": { - "type": "object", - "properties": { - "machine_type_ids": { + "available": { + "type": "boolean", + "nullable": true + }, + "unavailable_reason": { + "type": "string", + "nullable": true + }, + "grains": { "type": "array", "items": { - "type": "integer" + "type": "string" } }, - "machine_ids": { + "chart_hint": { + "type": "string" + }, + "chart_spec": { + "allOf": [ + { + "$ref": "#/components/schemas/ReportChartSpec" + } + ], + "nullable": true + }, + "identity_bearing": { + "type": "boolean" + }, + "aggregate_supported": { + "type": "boolean" + }, + "allowed_formats": { "type": "array", "items": { - "type": "integer" + "$ref": "#/components/schemas/AllowedFormatsEnum" } }, - "category_ids": { + "schedulable": { + "type": "boolean" + }, + "previewable": { + "type": "boolean" + }, + "condition_metrics": { "type": "array", "items": { - "type": "integer" + "type": "string" } } - } + }, + "required": [ + "aggregate_supported", + "allowed_formats", + "available", + "chart_hint", + "chart_spec", + "condition_metrics", + "exportable", + "fields", + "grains", + "identity_bearing", + "key", + "previewable", + "required_modules", + "schedulable", + "summary", + "title", + "unavailable_reason" + ] }, - "RecipientScopeOption": { + "ReportChartAxis": { "type": "object", "properties": { - "id": { - "type": "integer" - }, - "name": { + "field": { "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/ReportChartAxisKindEnum" } }, "required": [ - "id", - "name" + "field", + "kind" ] }, - "RecipientScopeOptions": { + "ReportChartAxisKindEnum": { + "enum": [ + "time", + "category" + ], + "type": "string", + "description": "* `time` - time\n* `category` - category" + }, + "ReportChartSpec": { "type": "object", "properties": { - "machine_types": { + "series": { "type": "array", "items": { - "$ref": "#/components/schemas/RecipientScopeOption" + "type": "string" } }, - "machines": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RecipientScopeOption" - } + "x": { + "$ref": "#/components/schemas/ReportChartAxis" }, - "categories": { + "values": { "type": "array", "items": { - "$ref": "#/components/schemas/RecipientScopeOption" - } + "$ref": "#/components/schemas/ReportChartValue" + }, + "description": "Empty for detail tables without numeric measures." + }, + "currency_field": { + "type": "string", + "nullable": true + }, + "partition": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PartitionEnum" + }, + { + "$ref": "#/components/schemas/NullEnum" + } + ] + }, + "negatives": { + "$ref": "#/components/schemas/NegativesEnum" + }, + "truncation": { + "allOf": [ + { + "$ref": "#/components/schemas/ReportChartTruncation" + } + ], + "nullable": true + }, + "alt_template": { + "type": "string" } }, "required": [ - "categories", - "machine_types", - "machines" + "alt_template", + "currency_field", + "negatives", + "partition", + "series", + "truncation", + "values", + "x" ] }, - "RecoveryAcknowledge": { + "ReportChartTruncation": { "type": "object", "properties": { - "acknowledgement": { + "top_n": { + "type": "integer", + "minimum": 1 + }, + "other_label": { "type": "string" } }, "required": [ - "acknowledgement" + "other_label", + "top_n" ] }, - "RecoveryState": { + "ReportChartValue": { "type": "object", "properties": { - "mode": { + "field": { + "type": "string" + }, + "unit": { + "type": "string" + }, + "kind": { "allOf": [ { - "$ref": "#/components/schemas/RecoveryStateModeEnum" + "$ref": "#/components/schemas/ReportChartValueKindEnum" } ], - "readOnly": true + "description": "Optional series rendering: exclusive status counts stack; cumulative rates use lines.\n\n* `line` - line\n* `stacked_bar` - stacked_bar" + } + }, + "required": [ + "field", + "unit" + ] + }, + "ReportChartValueKindEnum": { + "enum": [ + "line", + "stacked_bar" + ], + "type": "string", + "description": "* `line` - line\n* `stacked_bar` - stacked_bar" + }, + "ReportCondition": { + "type": "object", + "properties": { + "non_empty": { + "type": "boolean" }, - "auth_generation": { + "metric": { "type": "string", - "format": "uuid", - "readOnly": true + "description": "Sum this numeric column across the matching report rows.", + "maxLength": 80 }, - "active_restore": { + "operator": { + "$ref": "#/components/schemas/OperatorEnum" + }, + "value": { "type": "string", - "format": "uuid", - "readOnly": true, - "nullable": true + "format": "decimal", + "pattern": "^-?\\d{0,14}(?:\\.\\d{0,6})?$" }, - "recovery_principal": { + "currency": { + "type": "string", + "pattern": "^[a-z]{3}$" + } + } + }, + "ReportDelivery": { + "type": "object", + "properties": { + "id": { "type": "integer", - "readOnly": true, - "nullable": true + "readOnly": true }, - "quarantine_reason": { + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/ReportDeliveryStatusEnum" + } + ], + "readOnly": true + }, + "error": { "type": "string", "readOnly": true }, - "quarantined_at": { + "created_at": { "type": "string", "format": "date-time", - "readOnly": true, - "nullable": true + "readOnly": true }, - "acknowledged_at": { + "expires_at": { "type": "string", "format": "date-time", "readOnly": true, "nullable": true }, - "acknowledged_by": { - "type": "integer", - "readOnly": true, - "nullable": true - }, - "acknowledgement": { - "type": "string", - "readOnly": true - }, - "residual_risk": { - "type": "string", - "readOnly": true - }, - "updated_at": { + "download_url": { "type": "string", - "format": "date-time", + "format": "uri", "readOnly": true } }, "required": [ - "acknowledged_at", - "acknowledged_by", - "acknowledgement", - "active_restore", - "auth_generation", - "mode", - "quarantine_reason", - "quarantined_at", - "recovery_principal", - "residual_risk", - "updated_at" + "created_at", + "download_url", + "error", + "expires_at", + "id", + "status" ] }, - "RecoveryStateModeEnum": { + "ReportDeliveryStatusEnum": { "enum": [ - "normal", - "target_import", - "quiesced", - "quarantined" + "sent", + "failed" ], "type": "string", - "description": "* `normal` - Normal\n* `target_import` - Target import\n* `quiesced` - Quiesced\n* `quarantined` - Quarantined" + "description": "* `sent` - Sent\n* `failed` - Failed" }, - "RecoveryUnavailable": { + "ReportError": { "type": "object", "properties": { "detail": { @@ -99146,162 +122202,154 @@ } }, "required": [ - "code", "detail" ] }, - "RecurrenceResult": { - "type": "object", - "properties": { - "occurrences": { - "type": "array", - "items": { - "$ref": "#/components/schemas/OccurrenceResult" - } - } - }, - "required": [ - "occurrences" - ] + "ReportKeyEnum": { + "enum": [ + "payment-cash-book", + "taken-items", + "active-loans", + "returns", + "damaged-missing", + "top-borrowers", + "recently-added", + "event-attendance", + "booking-utilization", + "member-activity", + "printer-service", + "fablab-health", + "payment-reconciliation", + "evidence-compliance", + "import-quality", + "communications-health", + "community-engagement", + "certification-coverage", + "audit-trail", + "membership-growth", + "member-retention", + "membership-revenue", + "payments-ledger", + "revenue-over-time", + "cash-book", + "payments-ageing", + "accounting-journal", + "event-registrations", + "event-feedback", + "event-attendance-pdf", + "booking-resource-utilization", + "booking-resource-utilization-pdf", + "booking-reservations", + "booking-answers", + "mobile-devices", + "mobile-auth", + "booking-occupancy", + "booking-lead-time", + "booking-top-members", + "event-signups", + "event-revenue", + "event-funnel", + "event-checkin-times", + "event-feedback-scores", + "most-lent", + "asset-usage", + "loan-throughput", + "inventory-utilisation", + "overdue-loans", + "damaged-lost", + "longest-loans", + "stock-health", + "request-funnel", + "problem-reports", + "inventory-watch-demand", + "summary", + "qr-scans", + "qr-scan-actors", + "inventory-control", + "consumable-usage", + "consumable-pools", + "procurement-performance", + "stock-transfers", + "stocktake-variance", + "machine-usage", + "machine-usage-members", + "machine-usage-heatmap", + "machine-downtime", + "maintenance-activity", + "machine-service", + "service-queue-times", + "printer-jobs", + "machine-errors", + "warranty-expiry", + "device-health", + "presence-activity", + "presence-top-visitors", + "presence-activity-hours", + "audit-activity", + "audit-top-actors", + "audit-activity-hours", + "organization-membership", + "module-operational-health", + "platform-services", + "platform-usage" + ], + "type": "string", + "description": "* `payment-cash-book` - payment-cash-book\n* `taken-items` - taken-items\n* `active-loans` - active-loans\n* `returns` - returns\n* `damaged-missing` - damaged-missing\n* `top-borrowers` - top-borrowers\n* `recently-added` - recently-added\n* `event-attendance` - event-attendance\n* `booking-utilization` - booking-utilization\n* `member-activity` - member-activity\n* `printer-service` - printer-service\n* `fablab-health` - fablab-health\n* `payment-reconciliation` - payment-reconciliation\n* `evidence-compliance` - evidence-compliance\n* `import-quality` - import-quality\n* `communications-health` - communications-health\n* `community-engagement` - community-engagement\n* `certification-coverage` - certification-coverage\n* `audit-trail` - audit-trail\n* `membership-growth` - membership-growth\n* `member-retention` - member-retention\n* `membership-revenue` - membership-revenue\n* `payments-ledger` - payments-ledger\n* `revenue-over-time` - revenue-over-time\n* `cash-book` - cash-book\n* `payments-ageing` - payments-ageing\n* `accounting-journal` - accounting-journal\n* `event-registrations` - event-registrations\n* `event-feedback` - event-feedback\n* `event-attendance-pdf` - event-attendance-pdf\n* `booking-resource-utilization` - booking-resource-utilization\n* `booking-resource-utilization-pdf` - booking-resource-utilization-pdf\n* `booking-reservations` - booking-reservations\n* `booking-answers` - booking-answers\n* `mobile-devices` - mobile-devices\n* `mobile-auth` - mobile-auth\n* `booking-occupancy` - booking-occupancy\n* `booking-lead-time` - booking-lead-time\n* `booking-top-members` - booking-top-members\n* `event-signups` - event-signups\n* `event-revenue` - event-revenue\n* `event-funnel` - event-funnel\n* `event-checkin-times` - event-checkin-times\n* `event-feedback-scores` - event-feedback-scores\n* `most-lent` - most-lent\n* `asset-usage` - asset-usage\n* `loan-throughput` - loan-throughput\n* `inventory-utilisation` - inventory-utilisation\n* `overdue-loans` - overdue-loans\n* `damaged-lost` - damaged-lost\n* `longest-loans` - longest-loans\n* `stock-health` - stock-health\n* `request-funnel` - request-funnel\n* `problem-reports` - problem-reports\n* `inventory-watch-demand` - inventory-watch-demand\n* `summary` - summary\n* `qr-scans` - qr-scans\n* `qr-scan-actors` - qr-scan-actors\n* `inventory-control` - inventory-control\n* `consumable-usage` - consumable-usage\n* `consumable-pools` - consumable-pools\n* `procurement-performance` - procurement-performance\n* `stock-transfers` - stock-transfers\n* `stocktake-variance` - stocktake-variance\n* `machine-usage` - machine-usage\n* `machine-usage-members` - machine-usage-members\n* `machine-usage-heatmap` - machine-usage-heatmap\n* `machine-downtime` - machine-downtime\n* `maintenance-activity` - maintenance-activity\n* `machine-service` - machine-service\n* `service-queue-times` - service-queue-times\n* `printer-jobs` - printer-jobs\n* `machine-errors` - machine-errors\n* `warranty-expiry` - warranty-expiry\n* `device-health` - device-health\n* `presence-activity` - presence-activity\n* `presence-top-visitors` - presence-top-visitors\n* `presence-activity-hours` - presence-activity-hours\n* `audit-activity` - audit-activity\n* `audit-top-actors` - audit-top-actors\n* `audit-activity-hours` - audit-activity-hours\n* `organization-membership` - organization-membership\n* `module-operational-health` - module-operational-health\n* `platform-services` - platform-services\n* `platform-usage` - platform-usage" }, - "RecurringBookingInput": { + "ReportSchedule": { "type": "object", "properties": { - "certification_override_reason": { - "type": "string", - "description": "Requires existing authority over the linked machine type; audited.", - "maxLength": 1000 - }, - "starts_at": { - "type": "string", - "format": "date-time", - "writeOnly": true + "id": { + "type": "integer", + "readOnly": true }, - "ends_at": { - "type": "string", - "format": "date-time", - "writeOnly": true + "makerspace": { + "type": "integer", + "readOnly": true }, - "custom_answers": { - "writeOnly": true, - "nullable": true + "report_key": { + "$ref": "#/components/schemas/ReportKeyEnum" }, - "member_id": { - "type": "integer", - "maximum": 9223372036854775807, - "minimum": 1, - "format": "int64" + "filters": { + "$ref": "#/components/schemas/ReportScheduleFilters" }, - "note": { + "grain": { "type": "string", - "maxLength": 5000 + "maxLength": 8 }, - "count": { - "type": "integer", - "maximum": 52, - "minimum": 2 + "format": { + "$ref": "#/components/schemas/ReportScheduleFormatEnum" }, - "interval_weeks": { - "type": "integer", - "maximum": 12, - "minimum": 1, - "default": 1 - } - }, - "required": [ - "count", - "ends_at", - "member_id", - "starts_at" - ] - }, - "ReferralCreate": { - "type": "object", - "properties": { - "invite_email": { - "type": "string", - "format": "email", - "maxLength": 254 - } - }, - "required": [ - "invite_email" - ] - }, - "ReferralOutcome": { - "type": "object", - "properties": { - "state": { - "type": "string" - } - }, - "required": [ - "state" - ] - }, - "RefreshResponse": { - "type": "object", - "properties": { - "access": { - "type": "string" - } - }, - "required": [ - "access" - ] - }, - "Refund": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "readOnly": true + "cadence": { + "$ref": "#/components/schemas/CadenceEnum" }, - "amount": { + "next_run_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "readOnly": true + "format": "date-time" }, - "currency": { + "last_run_at": { "type": "string", - "readOnly": true - }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/RefundStatusEnum" - } - ], - "readOnly": true - }, - "provider": { - "allOf": [ - { - "$ref": "#/components/schemas/RefundProviderEnum" - } - ], - "readOnly": true + "format": "date-time", + "readOnly": true, + "nullable": true }, - "reason": { - "type": "string", - "readOnly": true + "is_active": { + "type": "boolean" }, - "method": { - "allOf": [ - { - "$ref": "#/components/schemas/Method2edEnum" - } - ], - "readOnly": true + "destination": { + "type": "integer", + "nullable": true }, - "reference": { - "type": "string", - "readOnly": true + "recipient_emails": { + "type": "array", + "items": { + "type": "string", + "format": "email" + }, + "maxItems": 10 }, - "refunded_at": { - "type": "string", - "format": "date-time", + "created_by": { + "type": "integer", "readOnly": true, "nullable": true }, @@ -99310,650 +122358,709 @@ "format": "date-time", "readOnly": true }, - "settled_at": { + "updated_at": { "type": "string", "format": "date-time", - "readOnly": true, - "nullable": true - }, - "amends": { - "type": "integer", - "readOnly": true, - "nullable": true + "readOnly": true }, - "corrected": { - "type": "boolean", + "last_delivery": { + "allOf": [ + { + "$ref": "#/components/schemas/ReportDelivery" + } + ], + "nullable": true, "readOnly": true + }, + "condition": { + "$ref": "#/components/schemas/ReportCondition" + }, + "recipient_roles": { + "type": "array", + "items": { + "type": "string", + "maxLength": 80, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "maxItems": 20 + }, + "recipient_actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecipientActionsEnum" + }, + "maxItems": 20 } }, "required": [ - "amends", - "amount", - "corrected", "created_at", - "currency", + "created_by", "id", - "method", - "provider", - "reason", - "reference", - "refunded_at", - "settled_at", - "status" + "last_delivery", + "last_run_at", + "makerspace", + "report_key", + "updated_at" ] }, - "RefundProviderEnum": { - "enum": [ - "stripe", - "razorpay", - "manual" - ], - "type": "string", - "description": "* `stripe` - Stripe\n* `razorpay` - Razorpay\n* `manual` - Manual" + "ReportScheduleFilters": { + "type": "object", + "description": "The subset of manual-export query parameters a schedule may pin.\n\n`window_days` is the recurring-report shape (\"the last 7 days, every Monday\"); fixed\n`start`/`end` reproduce the same file each run and are mutually exclusive with it.", + "properties": { + "start": { + "type": "string", + "format": "date" + }, + "end": { + "type": "string", + "format": "date" + }, + "window_days": { + "type": "integer", + "maximum": 366, + "minimum": 1 + }, + "status": { + "$ref": "#/components/schemas/Status66aEnum" + }, + "subject_type": { + "$ref": "#/components/schemas/SubjectTypeEnum" + }, + "months": { + "type": "integer", + "maximum": 120, + "minimum": 1 + }, + "subject_kind": { + "type": "string", + "maxLength": 48 + }, + "subject_id": { + "type": "integer", + "minimum": 1 + } + } }, - "RefundStatusEnum": { + "ReportScheduleFormatEnum": { "enum": [ - "pending", - "succeeded", - "failed" + "csv", + "xlsx" ], "type": "string", - "description": "* `pending` - Pending\n* `succeeded` - Succeeded\n* `failed` - Failed" - }, - "Rehydrate": { - "type": "object", - "properties": { - "destination": { - "type": "integer" - } - }, - "required": [ - "destination" - ] + "description": "* `csv` - CSV\n* `xlsx` - XLSX" }, - "RejectRequest": { + "ReportViewState": { "type": "object", "properties": { - "reason": { - "type": "string" + "report_key": { + "$ref": "#/components/schemas/ReportKeyEnum" + }, + "filters": { + "$ref": "#/components/schemas/ReportScheduleFilters" + }, + "grain": { + "type": "string", + "maxLength": 8 + }, + "columns": { + "type": "array", + "items": { + "type": "string", + "maxLength": 80 + }, + "maxItems": 100 } }, "required": [ - "reason" + "report_key" ] }, - "RejoinDuesRuleEnum": { - "enum": [ - "once", - "each_join" - ], - "type": "string", - "description": "* `once` - Joining dues once\n* `each_join` - Joining dues on each join" - }, - "RelationshipEnum": { + "RequestAccessEnum": { "enum": [ - "owner", - "manager", - "affiliate" + "anyone" ], "type": "string", - "description": "* `owner` - Owner\n* `manager` - Manager\n* `affiliate` - Affiliate" + "description": "* `anyone` - anyone" }, - "ReorderDraft": { + "RequestItemInput": { "type": "object", "properties": { "product_id": { + "type": "integer" + }, + "quantity": { "type": "integer", + "maximum": 99, "minimum": 1 } }, "required": [ - "product_id" + "product_id", + "quantity" ] }, - "ReorderSuggestion": { + "RequestSubmit": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "name": { - "type": "string", - "readOnly": true - }, - "manufacturer": { + "website": { "type": "string", - "readOnly": true + "writeOnly": true }, - "model": { + "contact_name": { "type": "string", - "readOnly": true + "description": "Required for an account-less submission.", + "maxLength": 200 }, - "sku": { - "type": "string", - "readOnly": true + "contact_email": { + "description": "Required for an account-less submission; normalized to lowercase.", + "oneOf": [ + { + "type": "string", + "format": "email", + "maxLength": 254 + }, + { + "type": "string", + "maxLength": 0 + } + ] }, - "barcode": { + "contact_phone": { "type": "string", - "readOnly": true + "maxLength": 32 }, - "supplier": { + "requested_for": { "type": "string", - "readOnly": true - }, - "available_quantity": { - "type": "integer", - "readOnly": true - }, - "reorder_point": { - "type": "integer", - "readOnly": true + "default": "", + "maxLength": 500 }, - "reorder_quantity": { - "type": "integer", - "readOnly": true + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RequestItemInput" + }, + "maxItems": 20 } }, "required": [ - "available_quantity", - "barcode", - "id", - "manufacturer", - "model", - "name", - "reorder_point", - "reorder_quantity", - "sku", - "supplier" + "items" ] }, - "ReplayDirectHandout": { + "RequestSubmitResponse": { "type": "object", "properties": { - "kit_checklist": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KitCount" - }, - "maxItems": 3550 - }, - "borrower_id": { - "type": "integer" - }, - "remark": { - "type": "string" - }, - "container_id": { - "type": "integer", - "nullable": true - }, - "qr_payloads": { - "type": "array", - "items": { - "type": "string", - "maxLength": 64 - }, - "maxItems": 50 + "public_token": { + "type": "string", + "format": "uuid", + "readOnly": true }, - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DirectLoanItem" - } + "status": { + "type": "string", + "readOnly": true } }, "required": [ - "borrower_id" + "public_token", + "status" ] }, - "ReplayDirectReturn": { + "RequestTimelineResponse": { "type": "object", "properties": { - "loan_id": { - "type": "integer", - "minimum": 1 - }, - "kit_checklist": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KitCount" - }, - "maxItems": 3550 + "request_id": { + "type": "integer" }, - "notes": { - "type": "string" + "limit": { + "type": "integer" }, - "qr_payload": { - "type": "string", - "maxLength": 64 + "truncated": { + "type": "boolean" }, - "resolutions": { + "events": { "type": "array", "items": { - "$ref": "#/components/schemas/ReturnItemResolution" + "$ref": "#/components/schemas/TimelineEvent" } } }, "required": [ - "notes" + "events", + "limit", + "request_id", + "truncated" ] }, - "ReplayDomainPayload": { + "ResetPasswordConfirmError": { "oneOf": [ { - "$ref": "#/components/schemas/KioskAssignBox" - }, - { - "$ref": "#/components/schemas/ReplayReviewedIssue" + "$ref": "#/components/schemas/PasswordResetFailure" }, { - "$ref": "#/components/schemas/ReplayReviewedReturn" - }, + "$ref": "#/components/schemas/PasswordValidationFailure" + } + ] + }, + "ResetPasswordConfirmRequest": { + "oneOf": [ { - "$ref": "#/components/schemas/ReplayDirectHandout" + "$ref": "#/components/schemas/OtpResetPasswordConfirm" }, { - "$ref": "#/components/schemas/ReplayDirectReturn" + "$ref": "#/components/schemas/LegacyResetPasswordConfirm" } ] }, - "ReplayEnvelope": { + "ResetPasswordRequest": { "type": "object", "properties": { - "client_command_id": { - "type": "string", - "format": "uuid" - }, - "kind": { - "$ref": "#/components/schemas/ReplayEnvelopeKindEnum" - }, - "version": { - "type": "integer", - "minimum": 1 - }, - "aggregate_id": { - "type": "string", - "format": "uuid" - }, - "sequence": { - "type": "integer", - "maximum": 10000, - "minimum": 1 - }, - "predecessor_client_id": { - "type": "string", - "format": "uuid", - "nullable": true - }, - "client_created_at": { + "password": { "type": "string", - "format": "date-time" - }, - "payload": { - "$ref": "#/components/schemas/ReplayDomainPayload" + "writeOnly": true, + "minLength": 8 + } + } + }, + "ResetPasswordResponse": { + "type": "object", + "properties": { + "username": { + "type": "string" }, - "evidence": { - "allOf": [ - { - "$ref": "#/components/schemas/KioskEvidencePrepare" - } - ], - "nullable": true + "temporary_password": { + "type": "string" } }, "required": [ - "aggregate_id", - "client_command_id", - "client_created_at", - "kind", - "payload", - "sequence", - "version" + "temporary_password", + "username" ] }, - "ReplayEnvelopeKindEnum": { + "ResolutionCodeEnum": { "enum": [ - "assign_box", - "reviewed_issue", - "reviewed_return", - "direct_handout", - "direct_return" + "acknowledged_no_change", + "replaced_online" ], "type": "string", - "description": "* `assign_box` - assign_box\n* `reviewed_issue` - reviewed_issue\n* `reviewed_return` - reviewed_return\n* `direct_handout` - direct_handout\n* `direct_return` - direct_return" + "description": "* `acknowledged_no_change` - Acknowledged No Change\n* `replaced_online` - Replaced Online" }, - "ReplayRecovery": { + "ResponseResultEnum": { + "enum": [ + "pending", + "succeeded", + "retryable_failed", + "failed" + ], + "type": "string", + "description": "* `pending` - Pending\n* `succeeded` - Succeeded\n* `retryable_failed` - Retryable failure\n* `failed` - Failed" + }, + "RestoreCreate": { "type": "object", "properties": { - "id": { - "type": "string", - "format": "uuid", - "readOnly": true - }, - "makerspace_id": { - "type": "integer", - "readOnly": true - }, - "station_id": { - "type": "integer", - "readOnly": true - }, - "station_name": { - "type": "string" - }, - "station_kind": { - "type": "string" - }, - "operator_display": { - "type": "string", - "readOnly": true - }, - "operation_kind": { - "type": "string", - "maxLength": 24 - }, - "operation_version": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "aggregate_id": { + "archive": { "type": "string", "format": "uuid" }, - "sequence": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "state": { - "$ref": "#/components/schemas/ReplayRecoveryStateEnum" - }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "client_created_at": { - "type": "string", - "format": "date-time" - }, - "completed_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "expires_at": { - "type": "string", - "format": "date-time" - }, - "retained_until": { + "kind": { + "$ref": "#/components/schemas/KindB02Enum" + } + }, + "required": [ + "archive", + "kind" + ] + }, + "RestoreDecision": { + "type": "object", + "properties": { + "decision": { + "$ref": "#/components/schemas/RestoreDecisionDecisionEnum" + } + }, + "required": [ + "decision" + ] + }, + "RestoreDecisionDecisionEnum": { + "enum": [ + "proceed", + "reset", + "abort" + ], + "type": "string", + "description": "* `proceed` - proceed\n* `reset` - reset\n* `abort` - abort" + }, + "RestoreOperation": { + "type": "object", + "properties": { + "id": { "type": "string", + "format": "uuid", "readOnly": true }, - "failure_code": { - "type": "string", - "nullable": true, - "maxLength": 64 - }, - "failure_detail": { + "archive": { "type": "string", - "nullable": true, - "maxLength": 200 - }, - "subject_request_id": { - "type": "integer", - "nullable": true - }, - "subject_loan_id": { - "type": "integer", - "nullable": true - }, - "recovery_request_id": { - "type": "integer", - "nullable": true, + "format": "uuid", "readOnly": true }, - "recovery_loan_id": { - "type": "integer", - "nullable": true, + "kind": { + "allOf": [ + { + "$ref": "#/components/schemas/KindB02Enum" + } + ], "readOnly": true }, - "result_request_id": { - "type": "integer", - "nullable": true + "stage": { + "allOf": [ + { + "$ref": "#/components/schemas/StageEnum" + } + ], + "readOnly": true }, - "result_loan_id": { - "type": "integer", - "nullable": true + "decision": { + "allOf": [ + { + "$ref": "#/components/schemas/RestoreOperationDecisionEnum" + } + ], + "readOnly": true }, - "result_return_event_id": { - "type": "integer", - "nullable": true + "restore_diff": { + "readOnly": true }, - "predecessor_id": { + "decision_deadline_at": { "type": "string", - "format": "uuid", + "format": "date-time", + "readOnly": true, "nullable": true }, - "predecessor_state": { + "supervisor_heartbeat_at": { "type": "string", + "format": "date-time", + "readOnly": true, "nullable": true }, - "evidence_id": { - "type": "integer", - "nullable": true + "error_detail": { + "type": "string", + "readOnly": true }, - "can_view_evidence": { - "type": "boolean", + "requested_by_username_snapshot": { + "type": "string", "readOnly": true }, - "resolution_code": { - "nullable": true, - "oneOf": [ - { - "$ref": "#/components/schemas/ResolutionCodeEnum" - }, - { - "$ref": "#/components/schemas/BlankEnum" - }, - { - "$ref": "#/components/schemas/NullEnum" - } - ] + "requested_at": { + "type": "string", + "format": "date-time", + "readOnly": true }, - "resolved_at": { + "completed_at": { "type": "string", "format": "date-time", + "readOnly": true, "nullable": true }, - "resolved_by_display": { + "updated_at": { "type": "string", - "nullable": true, + "format": "date-time", "readOnly": true - }, - "replacement_command_id": { - "type": "string", - "format": "uuid", - "nullable": true } }, "required": [ - "aggregate_id", - "can_view_evidence", - "client_created_at", - "created_at", - "evidence_id", - "expires_at", + "archive", + "completed_at", + "decision", + "decision_deadline_at", + "error_detail", "id", - "makerspace_id", - "operation_kind", - "operation_version", - "operator_display", - "predecessor_id", - "recovery_loan_id", - "recovery_request_id", - "replacement_command_id", - "resolved_by_display", - "result_loan_id", - "result_request_id", - "result_return_event_id", - "retained_until", - "sequence", - "station_id", - "station_kind", - "station_name", - "subject_loan_id", - "subject_request_id" + "kind", + "requested_at", + "requested_by_username_snapshot", + "restore_diff", + "stage", + "supervisor_heartbeat_at", + "updated_at" ] }, - "ReplayRecoveryStateEnum": { + "RestoreOperationDecisionEnum": { "enum": [ - "prepared", - "claimed", - "succeeded", - "failed" + "pending", + "proceed", + "reset", + "abort" ], "type": "string", - "description": "* `prepared` - Prepared\n* `claimed` - Claimed\n* `succeeded` - Succeeded\n* `failed` - Failed" + "description": "* `pending` - Pending\n* `proceed` - Proceed\n* `reset` - Proceed with authority reset\n* `abort` - Abort" }, - "ReplayResolution": { + "RestrictUser": { "type": "object", "properties": { - "resolution_code": { - "$ref": "#/components/schemas/ResolutionCodeEnum" + "reason": { + "type": "string" }, - "replacement_command_id": { - "type": "string", - "format": "uuid", - "nullable": true + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/RestrictUserStatusEnum" + } + ], + "default": "restricted" } }, "required": [ - "resolution_code" + "reason" ] }, - "ReplayResult": { + "RestrictUserStatusEnum": { + "enum": [ + "restricted", + "suspended" + ], + "type": "string", + "description": "* `restricted` - restricted\n* `suspended` - suspended" + }, + "Result": { "type": "object", "properties": { - "replay_id": { + "operation_id": { "type": "string", "format": "uuid" }, - "client_command_id": { + "external_job_id": { "type": "string", - "format": "uuid" - }, - "state": { - "$ref": "#/components/schemas/ReplayResultStateEnum" - }, - "kind": { - "type": "string" + "maxLength": 120 }, - "expires_at": { - "type": "string", - "format": "date-time" + "outcome": { + "$ref": "#/components/schemas/ResultOutcomeEnum" }, - "evidence_id": { + "actual_minutes": { "type": "integer", - "nullable": true + "maximum": 2147483647, + "minimum": 0 }, - "result_request_id": { - "type": "integer", - "nullable": true + "consumptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Consumption" + } }, - "result_loan_id": { - "type": "integer", - "nullable": true + "failure_reason": { + "type": "string", + "default": "", + "maxLength": 1000 }, - "result_return_event_id": { + "percent_complete": { "type": "integer", + "maximum": 100, + "minimum": 0, "nullable": true }, - "failure_code": { + "actual_grams": { "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", "nullable": true }, - "failure_detail": { + "actual_quantity": { "type": "string", - "nullable": true - }, - "upload": { - "allOf": [ - { - "$ref": "#/components/schemas/EvidenceUrlResponse" - } - ], + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", "nullable": true } }, "required": [ - "client_command_id", - "evidence_id", - "expires_at", - "failure_code", - "failure_detail", - "kind", - "replay_id", - "result_loan_id", - "result_request_id", - "result_return_event_id", - "state" + "actual_minutes", + "consumptions", + "external_job_id", + "operation_id", + "outcome" ] }, - "ReplayResultStateEnum": { + "ResultCodeEnum": { "enum": [ - "prepared", - "claimed", + "ok", + "out_of_labels", + "paper_empty", + "offline", + "printer_error", + "unknown_error" + ], + "type": "string", + "description": "* `ok` - ok\n* `out_of_labels` - out_of_labels\n* `paper_empty` - paper_empty\n* `offline` - offline\n* `printer_error` - printer_error\n* `unknown_error` - unknown_error" + }, + "ResultEnum": { + "enum": [ + "pending", "succeeded", + "retryable_failed", "failed" ], "type": "string", - "description": "* `prepared` - prepared\n* `claimed` - claimed\n* `succeeded` - succeeded\n* `failed` - failed" + "description": "* `pending` - Pending\n* `succeeded` - Succeeded\n* `retryable_failed` - Retryable failure\n* `failed` - Failed" }, - "ReplayReviewedIssue": { + "ResultOutcomeEnum": { + "enum": [ + "completed", + "failed" + ], + "type": "string", + "description": "* `completed` - completed\n* `failed` - failed" + }, + "Retry": { "type": "object", "properties": { - "request_id": { + "generation": { "type": "integer", - "minimum": 1 - }, - "kit_checklist": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KitCount" - }, - "maxItems": 3550 + "minimum": 0 + } + }, + "required": [ + "generation" + ] + }, + "ReturnAssetResolution": { + "type": "object", + "properties": { + "asset_id": { + "type": "integer" }, - "remark": { + "outcome": { + "$ref": "#/components/schemas/ReturnAssetResolutionOutcomeEnum" + } + }, + "required": [ + "asset_id", + "outcome" + ] + }, + "ReturnAssetResolutionOutcomeEnum": { + "enum": [ + "returned", + "damaged", + "missing" + ], + "type": "string", + "description": "* `returned` - returned\n* `damaged` - damaged\n* `missing` - missing" + }, + "ReturnDue": { + "type": "object", + "properties": { + "return_due_at": { "type": "string", - "default": "" + "format": "date-time", + "nullable": true + } + }, + "required": [ + "return_due_at" + ] + }, + "ReturnItemResolution": { + "type": "object", + "properties": { + "item_id": { + "type": "integer" }, - "asset_qr_payloads": { - "type": "array", - "items": { - "type": "string" - } + "returned": { + "type": "integer", + "minimum": 0, + "default": 0 }, - "rejects": { + "damaged": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "missing": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "assets": { "type": "array", "items": { - "$ref": "#/components/schemas/IssueReject" + "$ref": "#/components/schemas/ReturnAssetResolution" } } }, "required": [ - "request_id" + "item_id" ] }, - "ReplayReviewedReturn": { + "ReturnPolicy": { "type": "object", "properties": { - "request_id": { + "id": { + "type": "integer", + "readOnly": true + }, + "default_loan_days": { + "type": "integer", + "maximum": 365, + "minimum": 1 + }, + "loan_renewal_mode": { + "$ref": "#/components/schemas/LoanRenewalModeEnum" + }, + "loan_max_renewals": { + "type": "integer", + "maximum": 100, + "minimum": 0 + }, + "loan_renewal_days": { "type": "integer", + "maximum": 365, "minimum": 1 }, + "loan_max_active": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "loan_max_items": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "loan_reminder_offsets_hours": { + "type": "array", + "items": { + "type": "integer", + "minimum": -8760, + "maximum": 8760 + }, + "maxItems": 100, + "uniqueItems": true, + "description": "Signed whole hours from due time: negative before, zero due, positive overdue." + }, + "loan_escalate_overdue_days": { + "type": "integer", + "maximum": 365, + "minimum": 0 + } + }, + "required": [ + "id" + ] + }, + "ReturnReminderCronResponse": { + "type": "object", + "properties": { + "sent": { + "type": "integer" + }, + "skipped": { + "type": "integer" + } + }, + "required": [ + "sent", + "skipped" + ] + }, + "ReturnRequest": { + "type": "object", + "properties": { "kit_checklist": { "type": "array", "items": { @@ -99961,6 +123068,9 @@ }, "maxItems": 3550 }, + "evidence_id": { + "type": "integer" + }, "box_code": { "type": "string" }, @@ -99976,151 +123086,212 @@ }, "required": [ "box_code", + "evidence_id", "remark", - "request_id", "resolutions" ] }, - "ReportCatalog": { + "ReturnsReport": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReturnsReportRow" + } + } + }, + "required": [ + "rows", + "typed_rows" + ] + }, + "ReturnsReportRow": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "requester": { + "type": "string" + }, + "status": { + "type": "string" + }, + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "count": { + "type": "integer", + "maximum": 1, + "minimum": 1, + "description": "One loan per detail row; sum to count closed loans in this result." + } + }, + "required": [ + "closed_at", + "count", + "id", + "requester", + "status" + ] + }, + "RevenueOverTimeReport": { "type": "object", "properties": { - "results": { + "rows": { "type": "array", "items": { - "$ref": "#/components/schemas/ReportCatalogItem" + "type": "array", + "items": {} + } + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RevenuePeriod" } } }, "required": [ - "results" + "rows", + "typed_rows" ] }, - "ReportCatalogItem": { + "RevenuePeriod": { "type": "object", "properties": { - "key": { - "type": "string" + "makerspace_id": { + "type": "integer" }, - "title": { + "currency": { "type": "string" }, - "fields": { - "type": "array", - "items": { - "type": "string" - } - }, - "exportable": { - "type": "boolean" - }, - "summary": { - "type": "boolean" - }, - "required_modules": { - "type": "array", - "items": { - "type": "string" - } - }, - "available": { - "type": "boolean", - "nullable": true + "collected_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" }, - "unavailable_reason": { + "refunded_amount": { "type": "string", - "nullable": true + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" }, - "grains": { - "type": "array", - "items": { - "type": "string" - } + "period": { + "type": "string", + "format": "date" }, - "chart_hint": { + "subject_type": { "type": "string" }, - "aggregate_supported": { - "type": "boolean" - }, - "allowed_formats": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AllowedFormatsEnum" - } + "rail": { + "$ref": "#/components/schemas/RailEnum" }, - "schedulable": { - "type": "boolean" + "owed_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" }, - "previewable": { - "type": "boolean" + "waived_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" }, - "condition_metrics": { - "type": "array", - "items": { - "type": "string" - } + "net_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" } }, "required": [ - "aggregate_supported", - "allowed_formats", - "available", - "chart_hint", - "condition_metrics", - "exportable", - "fields", - "grains", - "key", - "previewable", - "required_modules", - "schedulable", - "summary", - "title", - "unavailable_reason" + "collected_amount", + "currency", + "net_amount", + "owed_amount", + "period", + "rail", + "refunded_amount", + "subject_type", + "waived_amount" ] }, - "ReportCondition": { + "Revoke": { "type": "object", "properties": { - "non_empty": { - "type": "boolean" - }, - "metric": { - "type": "string", - "description": "Sum this numeric column across the matching report rows.", - "maxLength": 80 - }, - "operator": { - "$ref": "#/components/schemas/OperatorEnum" - }, - "value": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,14}(?:\\.\\d{0,6})?$" - }, - "currency": { - "type": "string", - "pattern": "^[a-z]{3}$" + "reason": { + "type": "string" } } }, - "ReportDelivery": { + "Role": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "status": { - "allOf": [ + "makerspace_id": { + "type": "integer", + "readOnly": true + }, + "name": { + "type": "string", + "readOnly": true + }, + "slug": { + "type": "string", + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "granted_actions": { + "type": "array", + "items": { + "type": "string" + }, + "readOnly": true + }, + "legacy_role": { + "readOnly": true, + "nullable": true, + "oneOf": [ { - "$ref": "#/components/schemas/ReportDeliveryStatusEnum" + "$ref": "#/components/schemas/LegacyRoleEnum" + }, + { + "$ref": "#/components/schemas/NullEnum" } - ], + ] + }, + "is_default": { + "type": "boolean", "readOnly": true }, - "error": { - "type": "string", + "is_protected": { + "type": "boolean", + "readOnly": true + }, + "combinable": { + "type": "boolean", + "readOnly": true + }, + "exclusive": { + "type": "boolean", + "readOnly": true + }, + "member_count": { + "type": "integer", "readOnly": true }, "created_at": { @@ -100128,92 +123299,76 @@ "format": "date-time", "readOnly": true }, - "expires_at": { + "updated_at": { "type": "string", "format": "date-time", - "readOnly": true, - "nullable": true - }, - "download_url": { - "type": "string", - "format": "uri", "readOnly": true } }, "required": [ + "combinable", "created_at", - "download_url", - "error", - "expires_at", + "exclusive", + "granted_actions", "id", - "status" + "is_default", + "is_protected", + "legacy_role", + "makerspace_id", + "member_count", + "name", + "slug", + "updated_at" ] }, - "ReportDeliveryStatusEnum": { + "RoleCddEnum": { "enum": [ - "sent", - "failed" + "space_manager", + "inventory_manager", + "print_manager", + "machine_manager", + "event_manager", + "booking_manager", + "handover", + "custom" ], "type": "string", - "description": "* `sent` - Sent\n* `failed` - Failed" + "description": "* `space_manager` - Space Manager\n* `inventory_manager` - Inventory Manager\n* `print_manager` - Print Manager\n* `machine_manager` - Machine Manager\n* `event_manager` - Event Manager\n* `booking_manager` - Booking Manager\n* `handover` - Handover\n* `custom` - Custom" }, - "ReportError": { + "RoleId": { "type": "object", "properties": { - "detail": { - "type": "string" - }, - "code": { - "type": "string" + "role_id": { + "type": "integer" } }, "required": [ - "detail" + "role_id" ] }, - "ReportKeyEnum": { + "RoleSlugEnum": { "enum": [ - "payment-cash-book", - "summary", - "taken-items", - "active-loans", - "returns", - "damaged-missing", - "damaged-lost", - "qr-scans", - "most-lent", - "top-borrowers", - "recently-added", - "machine-usage", - "event-attendance", - "booking-utilization", - "maintenance-activity", - "member-activity", - "machine-service", - "printer-service", - "fablab-health", - "payment-reconciliation", - "loan-throughput", - "inventory-control", - "evidence-compliance", - "import-quality", - "procurement-performance", - "communications-health", - "community-engagement", - "certification-coverage", - "module-operational-health", - "audit-trail", - "member-retention", - "membership-revenue", - "payments-ledger", - "revenue-over-time", - "cash-book", - "accounting-journal" + "space_manager", + "inventory_manager", + "machine_manager", + "event_manager", + "booking_manager", + "handover" ], "type": "string", - "description": "* `payment-cash-book` - payment-cash-book\n* `summary` - summary\n* `taken-items` - taken-items\n* `active-loans` - active-loans\n* `returns` - returns\n* `damaged-missing` - damaged-missing\n* `damaged-lost` - damaged-lost\n* `qr-scans` - qr-scans\n* `most-lent` - most-lent\n* `top-borrowers` - top-borrowers\n* `recently-added` - recently-added\n* `machine-usage` - machine-usage\n* `event-attendance` - event-attendance\n* `booking-utilization` - booking-utilization\n* `maintenance-activity` - maintenance-activity\n* `member-activity` - member-activity\n* `machine-service` - machine-service\n* `printer-service` - printer-service\n* `fablab-health` - fablab-health\n* `payment-reconciliation` - payment-reconciliation\n* `loan-throughput` - loan-throughput\n* `inventory-control` - inventory-control\n* `evidence-compliance` - evidence-compliance\n* `import-quality` - import-quality\n* `procurement-performance` - procurement-performance\n* `communications-health` - communications-health\n* `community-engagement` - community-engagement\n* `certification-coverage` - certification-coverage\n* `module-operational-health` - module-operational-health\n* `audit-trail` - audit-trail\n* `member-retention` - member-retention\n* `membership-revenue` - membership-revenue\n* `payments-ledger` - payments-ledger\n* `revenue-over-time` - revenue-over-time\n* `cash-book` - cash-book\n* `accounting-journal` - accounting-journal" + "description": "* `space_manager` - space_manager\n* `inventory_manager` - inventory_manager\n* `machine_manager` - machine_manager\n* `event_manager` - event_manager\n* `booking_manager` - booking_manager\n* `handover` - handover" }, - "ReportSchedule": { + "RowKindEnum": { + "enum": [ + "status", + "machine", + "consumption", + "failure" + ], + "type": "string", + "description": "* `status` - status\n* `machine` - machine\n* `consumption` - consumption\n* `failure` - failure" + }, + "SavedReportView": { "type": "object", "properties": { "id": { @@ -100224,52 +123379,20 @@ "type": "integer", "readOnly": true }, - "report_key": { - "$ref": "#/components/schemas/ReportKeyEnum" - }, - "filters": { - "$ref": "#/components/schemas/ReportScheduleFilters" - }, - "grain": { - "type": "string", - "maxLength": 8 - }, - "format": { - "$ref": "#/components/schemas/ReportScheduleFormatEnum" - }, - "cadence": { - "$ref": "#/components/schemas/CadenceEnum" + "owner": { + "type": "integer", + "readOnly": true }, - "next_run_at": { + "name": { "type": "string", - "format": "date-time" + "maxLength": 80 }, - "last_run_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "state": { + "$ref": "#/components/schemas/ReportViewState" }, - "is_active": { + "shared": { "type": "boolean" }, - "destination": { - "type": "integer", - "nullable": true - }, - "recipient_emails": { - "type": "array", - "items": { - "type": "string", - "format": "email" - }, - "maxItems": 10 - }, - "created_by": { - "type": "integer", - "readOnly": true, - "nullable": true - }, "created_at": { "type": "string", "format": "date-time", @@ -100279,778 +123402,762 @@ "type": "string", "format": "date-time", "readOnly": true - }, - "last_delivery": { - "allOf": [ - { - "$ref": "#/components/schemas/ReportDelivery" - } - ], - "nullable": true, - "readOnly": true - }, - "condition": { - "$ref": "#/components/schemas/ReportCondition" - }, - "recipient_roles": { - "type": "array", - "items": { - "type": "string", - "maxLength": 80, - "pattern": "^[-a-zA-Z0-9_]+$" - }, - "maxItems": 20 - }, - "recipient_actions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RecipientActionsEnum" - }, - "maxItems": 20 } }, "required": [ "created_at", - "created_by", "id", - "last_delivery", - "last_run_at", "makerspace", - "report_key", + "name", + "owner", + "state", "updated_at" ] }, - "ReportScheduleFilters": { + "SavedReportViewList": { "type": "object", - "description": "The subset of manual-export query parameters a schedule may pin.\n\n`window_days` is the recurring-report shape (\"the last 7 days, every Monday\"); fixed\n`start`/`end` reproduce the same file each run and are mutually exclusive with it.", "properties": { - "start": { - "type": "string", - "format": "date" + "count": { + "type": "integer" }, - "end": { + "next": { "type": "string", - "format": "date" - }, - "window_days": { - "type": "integer", - "maximum": 366, - "minimum": 1 - }, - "status": { - "$ref": "#/components/schemas/Status66aEnum" - }, - "subject_type": { - "$ref": "#/components/schemas/SubjectTypeEnum" - }, - "months": { - "type": "integer", - "maximum": 120, - "minimum": 1 - } - } - }, - "ReportScheduleFormatEnum": { - "enum": [ - "csv", - "xlsx" - ], - "type": "string", - "description": "* `csv` - CSV\n* `xlsx` - XLSX" - }, - "ReportViewState": { - "type": "object", - "properties": { - "report_key": { - "$ref": "#/components/schemas/ReportKeyEnum" - }, - "filters": { - "$ref": "#/components/schemas/ReportScheduleFilters" + "format": "uri", + "nullable": true }, - "grain": { + "previous": { "type": "string", - "maxLength": 8 + "format": "uri", + "nullable": true }, - "columns": { + "results": { "type": "array", "items": { - "type": "string", - "maxLength": 80 - }, - "maxItems": 100 + "$ref": "#/components/schemas/SavedReportView" + } } }, "required": [ - "report_key" + "count", + "next", + "previous", + "results" ] }, - "RequestAccessEnum": { - "enum": [ - "anyone" - ], - "type": "string", - "description": "* `anyone` - anyone" - }, - "RequestItemInput": { + "SavedView": { "type": "object", "properties": { - "product_id": { - "type": "integer" - }, - "quantity": { + "id": { "type": "integer", - "maximum": 99, - "minimum": 1 - } - }, - "required": [ - "product_id", - "quantity" - ] - }, - "RequestSubmit": { - "type": "object", - "properties": { - "website": { - "type": "string", - "writeOnly": true + "readOnly": true }, - "contact_name": { - "type": "string", - "description": "Required for an account-less submission.", - "maxLength": 200 + "makerspace": { + "type": "integer", + "readOnly": true }, - "contact_email": { - "description": "Required for an account-less submission; normalized to lowercase.", - "oneOf": [ - { - "type": "string", - "format": "email", - "maxLength": 254 - }, - { - "type": "string", - "maxLength": 0 - } - ] + "owner": { + "type": "integer", + "readOnly": true }, - "contact_phone": { + "owner_username": { "type": "string", - "maxLength": 32 + "readOnly": true }, - "requested_for": { + "name": { "type": "string", - "default": "", - "maxLength": 500 + "maxLength": 80 }, - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RequestItemInput" - }, - "maxItems": 20 - } - }, - "required": [ - "items" - ] - }, - "RequestSubmitResponse": { - "type": "object", - "properties": { - "public_token": { + "entity": { + "$ref": "#/components/schemas/SavedViewEntityEnum" + }, + "state": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 250 + } + }, + "shared": { + "type": "boolean" + }, + "created_at": { "type": "string", - "format": "uuid", + "format": "date-time", "readOnly": true }, - "status": { + "updated_at": { "type": "string", + "format": "date-time", "readOnly": true } }, "required": [ - "public_token", - "status" + "created_at", + "entity", + "id", + "makerspace", + "name", + "owner", + "owner_username", + "state", + "updated_at" ] }, - "RequestTimelineResponse": { + "SavedViewEntityEnum": { + "enum": [ + "requests", + "inventory", + "members", + "payments", + "bookings", + "events", + "audit" + ], + "type": "string", + "description": "* `requests` - requests\n* `inventory` - inventory\n* `members` - members\n* `payments` - payments\n* `bookings` - bookings\n* `events` - events\n* `audit` - audit" + }, + "SavedViewList": { "type": "object", "properties": { - "request_id": { + "count": { "type": "integer" }, - "limit": { - "type": "integer" + "next": { + "type": "string", + "format": "uri", + "nullable": true }, - "truncated": { - "type": "boolean" + "previous": { + "type": "string", + "format": "uri", + "nullable": true }, - "events": { + "results": { "type": "array", "items": { - "$ref": "#/components/schemas/TimelineEvent" + "$ref": "#/components/schemas/SavedView" } } }, "required": [ - "events", - "limit", - "request_id", - "truncated" + "count", + "next", + "previous", + "results" ] }, - "ResetPasswordConfirmError": { - "oneOf": [ - { - "$ref": "#/components/schemas/PasswordResetFailure" + "Schedule": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true }, - { - "$ref": "#/components/schemas/PasswordValidationFailure" - } - ] - }, - "ResetPasswordConfirmRequest": { - "oneOf": [ - { - "$ref": "#/components/schemas/OtpResetPasswordConfirm" + "name": { + "type": "string", + "maxLength": 200 }, - { - "$ref": "#/components/schemas/LegacyResetPasswordConfirm" + "container": { + "type": "integer", + "nullable": true + }, + "interval_days": { + "type": "integer", + "minimum": 1 + }, + "next_due": { + "type": "string", + "format": "date" + }, + "blind": { + "type": "boolean" + }, + "is_active": { + "type": "boolean" } + }, + "required": [ + "id", + "interval_days", + "name", + "next_due" ] }, - "ResetPasswordRequest": { - "type": "object", - "properties": { - "password": { - "type": "string", - "writeOnly": true, - "minLength": 8 - } - } + "ScopeEnum": { + "enum": [ + "deployment", + "makerspace" + ], + "type": "string", + "description": "* `deployment` - Full deployment\n* `makerspace` - Makerspace" }, - "ResetPasswordResponse": { + "ScopeModeEnum": { + "enum": [ + "machine", + "full", + "handover" + ], + "type": "string", + "description": "* `machine` - machine\n* `full` - full\n* `handover` - handover" + }, + "SearchHit": { "type": "object", "properties": { - "username": { + "entity": { + "$ref": "#/components/schemas/SearchHitEntityEnum" + }, + "id": { + "type": "integer" + }, + "label": { "type": "string" }, - "temporary_password": { + "tab": { "type": "string" + }, + "query": { + "type": "object", + "additionalProperties": { + "type": "string" + } } }, "required": [ - "temporary_password", - "username" + "entity", + "id", + "label", + "query", + "tab" ] }, - "ResolutionCodeEnum": { - "enum": [ - "acknowledged_no_change", - "replaced_online" - ], - "type": "string", - "description": "* `acknowledged_no_change` - Acknowledged No Change\n* `replaced_online` - Replaced Online" - }, - "ResponseResultEnum": { + "SearchHitEntityEnum": { "enum": [ - "pending", - "succeeded", - "retryable_failed", - "failed" + "requests", + "inventory", + "members", + "bookings", + "events", + "machines" ], "type": "string", - "description": "* `pending` - Pending\n* `succeeded` - Succeeded\n* `retryable_failed` - Retryable failure\n* `failed` - Failed" + "description": "* `requests` - requests\n* `inventory` - inventory\n* `members` - members\n* `bookings` - bookings\n* `events` - events\n* `machines` - machines" }, - "RestoreCreate": { + "SearchOutput": { "type": "object", "properties": { - "archive": { - "type": "string", - "format": "uuid" - }, - "kind": { - "$ref": "#/components/schemas/KindB02Enum" + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchHit" + } } }, "required": [ - "archive", - "kind" + "results" ] }, - "RestoreDecision": { + "SectionOrderEnum": { + "enum": [ + "HERO", + "ABOUT", + "HIGHLIGHTS", + "SCHEDULE", + "SPEAKERS", + "SPONSORS", + "VENUE", + "FAQ", + "RESOURCES" + ], + "type": "string", + "description": "* `HERO` - HERO\n* `ABOUT` - ABOUT\n* `HIGHLIGHTS` - HIGHLIGHTS\n* `SCHEDULE` - SCHEDULE\n* `SPEAKERS` - SPEAKERS\n* `SPONSORS` - SPONSORS\n* `VENUE` - VENUE\n* `FAQ` - FAQ\n* `RESOURCES` - RESOURCES" + }, + "SegmentChoice": { "type": "object", "properties": { - "decision": { - "$ref": "#/components/schemas/RestoreDecisionDecisionEnum" + "id": { + "type": "integer" + }, + "name": { + "type": "string" } }, "required": [ - "decision" + "id", + "name" ] }, - "RestoreDecisionDecisionEnum": { + "SegmentSourceEnum": { "enum": [ - "proceed", - "reset", - "abort" + "billed", + "estimated", + "not_applicable" ], "type": "string", - "description": "* `proceed` - proceed\n* `reset` - reset\n* `abort` - abort" + "description": "* `billed` - billed\n* `estimated` - estimated\n* `not_applicable` - not_applicable" }, - "RestoreOperation": { + "SeriesCollaborationInbox": { "type": "object", "properties": { "id": { - "type": "string", - "format": "uuid", + "type": "integer", "readOnly": true }, - "archive": { - "type": "string", - "format": "uuid", + "series_id": { + "type": "integer", "readOnly": true }, - "kind": { - "allOf": [ - { - "$ref": "#/components/schemas/KindB02Enum" - } - ], + "series_title": { + "type": "string", "readOnly": true }, - "stage": { - "allOf": [ - { - "$ref": "#/components/schemas/StageEnum" - } - ], + "host_name": { + "type": "string", "readOnly": true }, - "decision": { + "host_slug": { + "type": "string", + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "status": { "allOf": [ { - "$ref": "#/components/schemas/RestoreOperationDecisionEnum" + "$ref": "#/components/schemas/StatusB9dEnum" } ], "readOnly": true }, - "restore_diff": { + "next_occurrence_at": { + "type": "string", + "format": "date-time", "readOnly": true }, - "decision_deadline_at": { + "created_at": { "type": "string", "format": "date-time", - "readOnly": true, - "nullable": true + "readOnly": true }, - "supervisor_heartbeat_at": { + "responded_at": { "type": "string", "format": "date-time", "readOnly": true, "nullable": true + } + }, + "required": [ + "created_at", + "host_name", + "host_slug", + "id", + "next_occurrence_at", + "responded_at", + "series_id", + "series_title", + "status" + ] + }, + "SeriesCollaborationRespond": { + "type": "object", + "properties": { + "accept": { + "type": "boolean" + } + }, + "required": [ + "accept" + ] + }, + "SeriesCollaborator": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true }, - "error_detail": { - "type": "string", + "series_id": { + "type": "integer", "readOnly": true }, - "requested_by_username_snapshot": { - "type": "string", + "makerspace_id": { + "type": "integer", "readOnly": true }, - "requested_at": { + "makerspace_name": { "type": "string", - "format": "date-time", "readOnly": true }, - "completed_at": { + "makerspace_slug": { "type": "string", - "format": "date-time", + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusB9dEnum" + } + ], + "readOnly": true + }, + "invited_by_id": { + "type": "integer", "readOnly": true, "nullable": true }, - "updated_at": { + "responded_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { "type": "string", "format": "date-time", "readOnly": true + }, + "responded_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true } }, "required": [ - "archive", - "completed_at", - "decision", - "decision_deadline_at", - "error_detail", + "created_at", "id", - "kind", - "requested_at", - "requested_by_username_snapshot", - "restore_diff", - "stage", - "supervisor_heartbeat_at", - "updated_at" + "invited_by_id", + "makerspace_id", + "makerspace_name", + "makerspace_slug", + "responded_at", + "responded_by_id", + "series_id", + "status" ] }, - "RestoreOperationDecisionEnum": { - "enum": [ - "pending", - "proceed", - "reset", - "abort" - ], - "type": "string", - "description": "* `pending` - Pending\n* `proceed` - Proceed\n* `reset` - Proceed with authority reset\n* `abort` - Abort" - }, - "RestrictUser": { + "SeriesCollaboratorReplace": { "type": "object", "properties": { - "reason": { - "type": "string" - }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/RestrictUserStatusEnum" - } - ], - "default": "restricted" + "slugs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[-a-zA-Z0-9_]+$" + } } }, "required": [ - "reason" + "slugs" ] }, - "RestrictUserStatusEnum": { - "enum": [ - "restricted", - "suspended" - ], - "type": "string", - "description": "* `restricted` - restricted\n* `suspended` - suspended" - }, - "Result": { + "ServiceAccept": { "type": "object", "properties": { - "operation_id": { - "type": "string", - "format": "uuid" - }, - "external_job_id": { - "type": "string", - "maxLength": 120 - }, - "outcome": { - "$ref": "#/components/schemas/ResultOutcomeEnum" - }, - "actual_minutes": { + "estimated_minutes": { "type": "integer", - "maximum": 2147483647, "minimum": 0 }, - "consumptions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Consumption" - } - }, - "failure_reason": { + "planned_grams": { "type": "string", - "default": "", - "maxLength": 1000 + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "percent_complete": { + "note": { + "type": "string" + } + } + }, + "ServiceComplete": { + "type": "object", + "properties": { + "actual_minutes": { "type": "integer", - "maximum": 100, - "minimum": 0, - "nullable": true + "minimum": 0 }, "actual_grams": { "type": "string", "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "nullable": true + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, "actual_quantity": { "type": "string", "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "nullable": true + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "consumptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ServiceConsumptionInput" + } } }, "required": [ - "actual_minutes", - "consumptions", - "external_job_id", - "operation_id", - "outcome" + "actual_minutes" ] }, - "ResultCodeEnum": { - "enum": [ - "ok", - "out_of_labels", - "paper_empty", - "offline", - "printer_error", - "unknown_error" - ], - "type": "string", - "description": "* `ok` - ok\n* `out_of_labels` - out_of_labels\n* `paper_empty` - paper_empty\n* `offline` - offline\n* `printer_error` - printer_error\n* `unknown_error` - unknown_error" - }, - "ResultEnum": { - "enum": [ - "pending", - "succeeded", - "retryable_failed", - "failed" - ], - "type": "string", - "description": "* `pending` - Pending\n* `succeeded` - Succeeded\n* `retryable_failed` - Retryable failure\n* `failed` - Failed" - }, - "ResultOutcomeEnum": { - "enum": [ - "completed", - "failed" - ], - "type": "string", - "description": "* `completed` - completed\n* `failed` - failed" - }, - "Retry": { + "ServiceConsumption": { "type": "object", "properties": { - "generation": { + "id": { "type": "integer", - "minimum": 0 - } - }, - "required": [ - "generation" - ] - }, - "ReturnAssetResolution": { - "type": "object", - "properties": { - "asset_id": { - "type": "integer" + "readOnly": true + }, + "machine_consumable_id": { + "type": "integer", + "readOnly": true + }, + "measurement": { + "type": "string", + "readOnly": true + }, + "product_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "label": { + "type": "string", + "readOnly": true + }, + "quantity": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true }, "outcome": { - "$ref": "#/components/schemas/ReturnAssetResolutionOutcomeEnum" + "type": "string", + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true } }, "required": [ - "asset_id", - "outcome" + "created_at", + "id", + "label", + "machine_consumable_id", + "measurement", + "outcome", + "product_id", + "quantity" ] }, - "ReturnAssetResolutionOutcomeEnum": { - "enum": [ - "returned", - "damaged", - "missing" - ], - "type": "string", - "description": "* `returned` - returned\n* `damaged` - damaged\n* `missing` - missing" - }, - "ReturnDue": { + "ServiceConsumptionInput": { "type": "object", "properties": { - "return_due_at": { + "machine_consumable_id": { + "type": "integer", + "minimum": 1 + }, + "quantity": { "type": "string", - "format": "date-time", - "nullable": true + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" } }, "required": [ - "return_due_at" + "machine_consumable_id", + "quantity" ] }, - "ReturnItemResolution": { + "ServiceFail": { "type": "object", "properties": { - "item_id": { - "type": "integer" - }, - "returned": { + "actual_minutes": { "type": "integer", - "minimum": 0, - "default": 0 + "minimum": 0 }, - "damaged": { - "type": "integer", - "minimum": 0, - "default": 0 + "actual_grams": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "missing": { - "type": "integer", - "minimum": 0, - "default": 0 + "actual_quantity": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "assets": { + "consumptions": { "type": "array", "items": { - "$ref": "#/components/schemas/ReturnAssetResolution" + "$ref": "#/components/schemas/ServiceConsumptionInput" } + }, + "reason": { + "type": "string" + }, + "percent_complete": { + "type": "integer", + "maximum": 100, + "minimum": 0 } }, "required": [ - "item_id" + "actual_minutes", + "percent_complete", + "reason" ] }, - "ReturnPolicy": { + "ServiceFile": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "default_loan_days": { - "type": "integer", - "maximum": 365, - "minimum": 1 - }, - "loan_renewal_mode": { - "$ref": "#/components/schemas/LoanRenewalModeEnum" - }, - "loan_max_renewals": { - "type": "integer", - "maximum": 100, - "minimum": 0 + "kind": { + "type": "string", + "readOnly": true }, - "loan_renewal_days": { - "type": "integer", - "maximum": 365, - "minimum": 1 + "original_filename": { + "type": "string", + "readOnly": true }, - "loan_max_active": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "content_type": { + "type": "string", + "readOnly": true }, - "loan_max_items": { + "size_bytes": { "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "readOnly": true }, - "loan_reminder_offsets_hours": { - "type": "array", - "items": { - "type": "integer", - "minimum": -8760, - "maximum": 8760 - }, - "maxItems": 100, - "uniqueItems": true, - "description": "Signed whole hours from due time: negative before, zero due, positive overdue." + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true }, - "loan_escalate_overdue_days": { + "attached_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "attached_at", + "content_type", + "created_at", + "id", + "kind", + "original_filename", + "size_bytes" + ] + }, + "ServiceFileFinalize": { + "type": "object", + "properties": { + "file_id": { "type": "integer", - "maximum": 365, - "minimum": 0 + "minimum": 1 } }, "required": [ - "id" + "file_id" ] }, - "ReturnReminderCronResponse": { + "ServiceFileFinalizeResponse": { "type": "object", "properties": { - "sent": { - "type": "integer" - }, - "skipped": { + "file_id": { "type": "integer" } }, "required": [ - "sent", - "skipped" + "file_id" ] }, - "ReturnRequest": { + "ServiceFilePresign": { "type": "object", "properties": { - "kit_checklist": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KitCount" - }, - "maxItems": 3550 + "filename": { + "type": "string", + "maxLength": 255 }, - "evidence_id": { + "content_type": { + "type": "string", + "maxLength": 128 + } + }, + "required": [ + "content_type", + "filename" + ] + }, + "ServiceFilePresignResponse": { + "type": "object", + "properties": { + "file_id": { "type": "integer" }, - "box_code": { - "type": "string" + "upload": { + "$ref": "#/components/schemas/ServiceFileUpload" + } + }, + "required": [ + "file_id", + "upload" + ] + }, + "ServiceFileUpload": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" }, - "remark": { + "method": { "type": "string" }, - "resolutions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ReturnItemResolution" - } + "fields": { + "type": "object", + "additionalProperties": {} + }, + "headers": { + "type": "object", + "additionalProperties": {} } }, "required": [ - "box_code", - "evidence_id", - "remark", - "resolutions" + "url" ] }, - "ReturnsReport": { + "ServiceFileUrl": { "type": "object", "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "typed_rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ReturnsReportRow" - } + "url": { + "type": "string", + "format": "uri" } }, "required": [ - "rows", - "typed_rows" + "url" ] }, - "ReturnsReportRow": { + "ServiceMachine": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" - }, "id": { - "type": "integer" + "type": "integer", + "readOnly": true }, - "requester": { - "type": "string" + "name": { + "type": "string", + "readOnly": true }, "status": { - "type": "string" - }, - "closed_at": { "type": "string", - "format": "date-time", - "nullable": true + "readOnly": true } }, "required": [ - "closed_at", "id", - "requester", + "name", "status" ] }, - "RevenueOverTimeReport": { + "ServiceQueueTimesReport": { "type": "object", "properties": { "rows": { @@ -101063,7 +124170,7 @@ "typed_rows": { "type": "array", "items": { - "$ref": "#/components/schemas/RevenuePeriod" + "$ref": "#/components/schemas/ServiceQueueTimesRow" } } }, @@ -101072,602 +124179,633 @@ "typed_rows" ] }, - "RevenuePeriod": { + "ServiceQueueTimesRow": { "type": "object", "properties": { "makerspace_id": { "type": "integer" }, - "currency": { - "type": "string" - }, - "collected_amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" - }, - "refunded_amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" - }, "period": { "type": "string", "format": "date" }, - "subject_type": { + "queue_id": { + "type": "integer", + "nullable": true + }, + "queue_name": { "type": "string" }, - "owed_amount": { + "bucket_id": { + "type": "integer", + "nullable": true + }, + "stage": { + "type": "string" + }, + "samples": { + "type": "integer" + }, + "median_minutes": { "type": "string", "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$", + "nullable": true }, - "waived_amount": { + "p90_minutes": { "type": "string", "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$", + "nullable": true }, - "net_amount": { + "p95_minutes": { "type": "string", "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + "pattern": "^-?\\d{0,22}(?:\\.\\d{0,2})?$", + "nullable": true } }, "required": [ - "collected_amount", - "currency", - "net_amount", - "owed_amount", + "bucket_id", + "median_minutes", + "p90_minutes", + "p95_minutes", "period", - "refunded_amount", - "subject_type", - "waived_amount" + "queue_id", + "queue_name", + "samples", + "stage" ] }, - "Revoke": { + "ServiceReject": { "type": "object", "properties": { "reason": { "type": "string" } - } + }, + "required": [ + "reason" + ] }, - "Role": { + "ServiceRequester": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "makerspace_id": { - "type": "integer", - "readOnly": true - }, - "name": { + "username": { "type": "string", "readOnly": true }, - "slug": { + "display": { "type": "string", - "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" - }, - "granted_actions": { - "type": "array", - "items": { - "type": "string" - }, - "readOnly": true - }, - "legacy_role": { - "readOnly": true, - "nullable": true, - "oneOf": [ - { - "$ref": "#/components/schemas/LegacyRoleEnum" - }, - { - "$ref": "#/components/schemas/NullEnum" - } - ] - }, - "is_default": { - "type": "boolean", "readOnly": true + } + }, + "required": [ + "display", + "id", + "username" + ] + }, + "ServiceStart": { + "type": "object", + "properties": { + "machine_id": { + "type": "integer", + "minimum": 1, + "nullable": true }, - "is_protected": { - "type": "boolean", - "readOnly": true + "estimated_minutes": { + "type": "integer", + "minimum": 0 }, - "member_count": { + "consumable_pool_id": { "type": "integer", - "readOnly": true + "minimum": 1 }, - "created_at": { + "planned_grams": { "type": "string", - "format": "date-time", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "updated_at": { + "planned_quantity": { "type": "string", - "format": "date-time", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" } - }, - "required": [ - "created_at", - "granted_actions", - "id", - "is_default", - "is_protected", - "legacy_role", - "makerspace_id", - "member_count", - "name", - "slug", - "updated_at" - ] - }, - "Role827Enum": { - "enum": [ - "space_manager", - "inventory_manager", - "print_manager", - "machine_manager", - "custom" - ], - "type": "string", - "description": "* `space_manager` - Space Manager\n* `inventory_manager` - Inventory Manager\n* `print_manager` - Print Manager\n* `machine_manager` - Machine Manager\n* `custom` - Custom" + } }, - "RoleCreate": { + "SetStatus": { "type": "object", "properties": { - "name": { + "status": { "type": "string", - "maxLength": 80 - }, - "granted_actions": { - "type": "array", - "items": { - "type": "string" - } + "maxLength": 20 } }, "required": [ - "granted_actions", - "name" + "status" ] }, - "RoleId": { + "SetupState": { "type": "object", "properties": { - "role_id": { - "type": "integer" + "profile_set": { + "type": "boolean" + }, + "hours_set": { + "type": "boolean" + }, + "first_item_added": { + "type": "boolean" + }, + "staff_invited": { + "type": "boolean" + }, + "modules_chosen": { + "type": "boolean" } }, "required": [ - "role_id" + "first_item_added", + "hours_set", + "modules_chosen", + "profile_set", + "staff_invited" ] }, - "RoleMachineScope": { + "SocialIdentity": { "type": "object", - "description": "The role's links plus everything it could be linked to, in one round trip.", "properties": { - "machine_type_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "machine_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "available_machine_types": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineScopeOption" - }, - "readOnly": true + "provider": { + "type": "string" }, - "available_machines": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineScopeOption" - }, - "readOnly": true + "created_at": { + "type": "string", + "format": "date-time" }, - "scoping_applies": { - "type": "boolean", - "readOnly": true + "updated_at": { + "type": "string", + "format": "date-time" } }, "required": [ - "available_machine_types", - "available_machines", - "machine_ids", - "machine_type_ids", - "scoping_applies" + "created_at", + "provider", + "updated_at" ] }, - "RoleMachineScopeWrite": { + "SocialLink": { "type": "object", "properties": { - "machine_type_ids": { - "type": "array", - "items": { - "type": "integer" - } + "provider": { + "type": "string", + "maxLength": 64 }, - "machine_ids": { - "type": "array", - "items": { - "type": "integer" - } + "id_token": { + "type": "string", + "writeOnly": true, + "maxLength": 16384 + }, + "nonce": { + "type": "string", + "writeOnly": true, + "maxLength": 512 + }, + "client_platform": { + "allOf": [ + { + "$ref": "#/components/schemas/ClientPlatformEnum" + } + ], + "default": "web" + }, + "apple_name": { + "type": "string", + "maxLength": 200 } }, "required": [ - "machine_ids", - "machine_type_ids" + "id_token", + "nonce", + "provider" ] }, - "RoleSlugEnum": { - "enum": [ - "space_manager", - "inventory_manager", - "machine_manager" - ], - "type": "string", - "description": "* `space_manager` - space_manager\n* `inventory_manager` - inventory_manager\n* `machine_manager` - machine_manager" - }, - "SavedReportView": { + "SocialLogin": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true + "authorization_code": { + "type": "string", + "writeOnly": true, + "description": "Optional Apple authorization code for later token revocation. Exchange failures never fail sign-in.", + "maxLength": 4096 }, - "makerspace": { - "type": "integer", - "readOnly": true + "redirect_uri": { + "description": "Original same-origin Apple web authorization redirect URI; required when exchanging a web code.", + "oneOf": [ + { + "type": "string", + "format": "uri", + "default": "", + "maxLength": 2048 + }, + { + "type": "string", + "maxLength": 0 + } + ] }, - "owner": { - "type": "integer", - "readOnly": true + "id_token": { + "type": "string", + "writeOnly": true, + "maxLength": 16384 }, - "name": { + "nonce": { "type": "string", - "maxLength": 80 + "writeOnly": true, + "maxLength": 512 }, - "state": { - "$ref": "#/components/schemas/ReportViewState" + "surface": { + "$ref": "#/components/schemas/Surface356Enum" }, - "shared": { - "type": "boolean" + "delivery": { + "$ref": "#/components/schemas/DeliveryEnum" }, - "created_at": { + "client_platform": { + "$ref": "#/components/schemas/ClientPlatformEnum" + }, + "apple_name": { "type": "string", - "format": "date-time", - "readOnly": true + "maxLength": 200 }, - "updated_at": { + "challenge": { "type": "string", - "format": "date-time", - "readOnly": true + "writeOnly": true, + "maxLength": 512 + }, + "attestation": { + "allOf": [ + { + "$ref": "#/components/schemas/DeviceAttestationPayload" + } + ], + "writeOnly": true } }, "required": [ - "created_at", - "id", - "makerspace", - "name", - "owner", - "state", - "updated_at" + "client_platform", + "delivery", + "id_token", + "nonce", + "surface" ] }, - "SavedReportViewList": { + "SocialLoginResponse": { "type": "object", "properties": { - "count": { - "type": "integer" + "access": { + "type": "string" }, - "next": { - "type": "string", - "format": "uri", - "nullable": true + "refresh": { + "type": "string" }, - "previous": { - "type": "string", - "format": "uri", - "nullable": true + "device_grant": { + "type": "object", + "additionalProperties": {} }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SavedReportView" - } + "user": { + "type": "object", + "additionalProperties": {} + }, + "outcome": { + "type": "string" } }, "required": [ - "count", - "next", - "previous", - "results" + "access", + "outcome", + "user" ] }, - "SavedView": { + "SocialNonce": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "makerspace": { - "type": "integer", - "readOnly": true - }, - "owner": { - "type": "integer", - "readOnly": true - }, - "owner_username": { - "type": "string", - "readOnly": true - }, - "name": { + "provider": { "type": "string", - "maxLength": 80 + "maxLength": 64 }, - "entity": { - "$ref": "#/components/schemas/SavedViewEntityEnum" + "surface": { + "$ref": "#/components/schemas/Surface356Enum" }, - "state": { - "type": "object", - "additionalProperties": { - "type": "string", - "maxLength": 250 - } + "delivery": { + "$ref": "#/components/schemas/DeliveryEnum" }, - "shared": { - "type": "boolean" + "client_platform": { + "$ref": "#/components/schemas/ClientPlatformEnum" }, - "created_at": { + "challenge": { "type": "string", - "format": "date-time", - "readOnly": true + "writeOnly": true, + "maxLength": 512 + } + }, + "required": [ + "client_platform", + "delivery", + "provider", + "surface" + ] + }, + "SocialNonceResponse": { + "type": "object", + "properties": { + "nonce": { + "type": "string" }, - "updated_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "expires_in": { + "type": "integer" } }, "required": [ - "created_at", - "entity", - "id", - "makerspace", - "name", - "owner", - "owner_username", - "state", - "updated_at" + "expires_in", + "nonce" ] }, - "SavedViewEntityEnum": { - "enum": [ - "requests", - "inventory", - "members", - "payments", - "bookings", - "events", - "audit" - ], - "type": "string", - "description": "* `requests` - requests\n* `inventory` - inventory\n* `members` - members\n* `payments` - payments\n* `bookings` - bookings\n* `events` - events\n* `audit` - audit" + "SpaceImageFinalizeRequest": { + "type": "object", + "properties": { + "object_key": { + "type": "string", + "maxLength": 500 + } + }, + "required": [ + "object_key" + ] }, - "SavedViewList": { + "SpaceImagePresignRequest": { "type": "object", "properties": { - "count": { - "type": "integer" - }, - "next": { + "filename": { "type": "string", - "format": "uri", - "nullable": true + "maxLength": 255 }, - "previous": { + "content_type": { "type": "string", - "format": "uri", - "nullable": true - }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SavedView" - } + "maxLength": 100 } }, "required": [ - "count", - "next", - "previous", - "results" + "content_type", + "filename" ] }, - "Schedule": { + "SpaceImagePresignResponse": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true + "object_key": { + "type": "string" }, - "name": { + "upload": { + "$ref": "#/components/schemas/SpaceImageUpload" + } + }, + "required": [ + "object_key", + "upload" + ] + }, + "SpaceImageUpload": { + "type": "object", + "properties": { + "url": { "type": "string", - "maxLength": 200 - }, - "container": { - "type": "integer", - "nullable": true - }, - "interval_days": { - "type": "integer", - "minimum": 1 + "format": "uri" }, - "next_due": { - "type": "string", - "format": "date" + "method": { + "$ref": "#/components/schemas/SpaceImageUploadMethodEnum" }, - "blind": { - "type": "boolean" + "fields": { + "type": "object", + "additionalProperties": {} }, - "is_active": { - "type": "boolean" + "headers": { + "type": "object", + "additionalProperties": {} } }, "required": [ - "id", - "interval_days", - "name", - "next_due" + "url" ] }, - "ScopeEnum": { - "enum": [ - "deployment", - "makerspace" - ], - "type": "string", - "description": "* `deployment` - Full deployment\n* `makerspace` - Makerspace" - }, - "ScopeModeEnum": { + "SpaceImageUploadMethodEnum": { "enum": [ - "machine", - "full" + "PUT" ], "type": "string", - "description": "* `machine` - machine\n* `full` - full" + "description": "* `PUT` - PUT" }, - "SearchHit": { + "SpacePack": { "type": "object", "properties": { - "entity": { - "$ref": "#/components/schemas/SearchHitEntityEnum" - }, - "id": { - "type": "integer" + "format": { + "$ref": "#/components/schemas/SpacePackFormatEnum" }, - "label": { - "type": "string" + "version": { + "type": "integer", + "maximum": 1, + "minimum": 1 }, - "tab": { - "type": "string" + "configuration": { + "allOf": [ + { + "$ref": "#/components/schemas/PackConfiguration" + } + ], + "description": "Configuration-only sections; see docs/SPACE-PACKS.md." }, - "query": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "checksum": { + "type": "string", + "maxLength": 64, + "minLength": 64 } }, "required": [ - "entity", - "id", - "label", - "query", - "tab" + "checksum", + "configuration", + "format", + "version" ] }, - "SearchHitEntityEnum": { + "SpacePackFormatEnum": { "enum": [ - "requests", - "inventory", - "members", - "bookings", - "events", - "machines" + "spaceworks-space-pack" ], "type": "string", - "description": "* `requests` - requests\n* `inventory` - inventory\n* `members` - members\n* `bookings` - bookings\n* `events` - events\n* `machines` - machines" + "description": "* `spaceworks-space-pack` - spaceworks-space-pack" }, - "SearchOutput": { + "SponsorTierEnum": { + "enum": [ + "platinum", + "gold", + "silver", + "partner", + "community_partner" + ], + "type": "string", + "description": "* `platinum` - platinum\n* `gold` - gold\n* `silver` - silver\n* `partner` - partner\n* `community_partner` - community_partner" + }, + "StaffBookingInput": { "type": "object", "properties": { - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SearchHit" - } + "certification_override_reason": { + "type": "string", + "description": "Requires existing authority over the linked machine type; audited.", + "maxLength": 1000 + }, + "starts_at": { + "type": "string", + "format": "date-time", + "writeOnly": true + }, + "ends_at": { + "type": "string", + "format": "date-time", + "writeOnly": true + }, + "custom_answers": { + "writeOnly": true, + "nullable": true + }, + "member_id": { + "type": "integer", + "maximum": 9223372036854775807, + "minimum": 1, + "format": "int64" + }, + "note": { + "type": "string", + "maxLength": 5000 } }, "required": [ - "results" + "ends_at", + "member_id", + "starts_at" ] }, - "SegmentChoice": { + "StaffGrant": { "type": "object", "properties": { "id": { - "type": "integer" + "type": "integer", + "readOnly": true }, - "name": { - "type": "string" + "makerspace": { + "type": "integer", + "readOnly": true + }, + "event": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "series": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "user": { + "type": "integer", + "readOnly": true + }, + "permissions": { + "readOnly": true + }, + "granted_by": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "revoked_by": { + "type": "integer", + "readOnly": true, + "nullable": true } }, "required": [ + "created_at", + "event", + "granted_by", "id", - "name" + "makerspace", + "permissions", + "revoked_at", + "revoked_by", + "series", + "user" ] }, - "SeriesCollaborationInbox": { + "StaffGrantInvite": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "series_id": { + "makerspace": { + "type": "integer", + "readOnly": true + }, + "event": { "type": "integer", - "readOnly": true + "readOnly": true, + "nullable": true }, - "series_title": { - "type": "string", - "readOnly": true + "series": { + "type": "integer", + "readOnly": true, + "nullable": true }, - "host_name": { - "type": "string", + "permissions": { "readOnly": true }, - "host_slug": { - "type": "string", + "created_by": { + "type": "integer", "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" + "nullable": true }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/StatusB9dEnum" - } - ], + "created_at": { + "type": "string", + "format": "date-time", "readOnly": true }, - "next_occurrence_at": { + "expires_at": { "type": "string", "format": "date-time", "readOnly": true }, - "created_at": { + "redeemed_at": { "type": "string", "format": "date-time", - "readOnly": true + "readOnly": true, + "nullable": true }, - "responded_at": { + "revoked_at": { "type": "string", "format": "date-time", "readOnly": true, @@ -101676,65 +124814,42 @@ }, "required": [ "created_at", - "host_name", - "host_slug", + "created_by", + "event", + "expires_at", "id", - "next_occurrence_at", - "responded_at", - "series_id", - "series_title", - "status" - ] - }, - "SeriesCollaborationRespond": { - "type": "object", - "properties": { - "accept": { - "type": "boolean" - } - }, - "required": [ - "accept" + "makerspace", + "permissions", + "redeemed_at", + "revoked_at", + "series" ] }, - "SeriesCollaborator": { + "StaffGrantInviteCreated": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "series_id": { + "makerspace": { "type": "integer", "readOnly": true }, - "makerspace_id": { + "event": { "type": "integer", - "readOnly": true - }, - "makerspace_name": { - "type": "string", - "readOnly": true - }, - "makerspace_slug": { - "type": "string", "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" - }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/StatusB9dEnum" - } - ], - "readOnly": true + "nullable": true }, - "invited_by_id": { + "series": { "type": "integer", "readOnly": true, "nullable": true }, - "responded_by_id": { + "permissions": { + "readOnly": true + }, + "created_by": { "type": "integer", "readOnly": true, "nullable": true @@ -101744,119 +124859,183 @@ "format": "date-time", "readOnly": true }, - "responded_at": { + "expires_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "redeemed_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "revoked_at": { "type": "string", "format": "date-time", "readOnly": true, "nullable": true + }, + "token": { + "type": "string", + "readOnly": true } }, "required": [ "created_at", + "created_by", + "event", + "expires_at", "id", - "invited_by_id", - "makerspace_id", - "makerspace_name", - "makerspace_slug", - "responded_at", - "responded_by_id", - "series_id", - "status" + "makerspace", + "permissions", + "redeemed_at", + "revoked_at", + "series", + "token" ] }, - "SeriesCollaboratorReplace": { + "StaffGrantInviteInput": { "type": "object", "properties": { - "slugs": { + "permissions": { "type": "array", "items": { - "type": "string", - "pattern": "^[-a-zA-Z0-9_]+$" - } + "$ref": "#/components/schemas/Permissions5d1Enum" + }, + "maxItems": 5, + "minItems": 1 + }, + "email": { + "oneOf": [ + { + "type": "string", + "format": "email", + "default": "" + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "send_email": { + "type": "boolean", + "default": false } }, "required": [ - "slugs" + "permissions" ] }, - "ServiceAccept": { + "StaffGrantInviteList": { "type": "object", "properties": { - "estimated_minutes": { - "type": "integer", - "minimum": 0 + "count": { + "type": "integer" }, - "planned_grams": { + "next": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "format": "uri", + "nullable": true }, - "note": { - "type": "string" + "previous": { + "type": "string", + "format": "uri", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StaffGrantInvite" + } } - } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] }, - "ServiceComplete": { + "StaffGrantList": { "type": "object", "properties": { - "actual_minutes": { - "type": "integer", - "minimum": 0 + "count": { + "type": "integer" }, - "actual_grams": { + "next": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "format": "uri", + "nullable": true }, - "actual_quantity": { + "previous": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "format": "uri", + "nullable": true }, - "consumptions": { + "results": { "type": "array", "items": { - "$ref": "#/components/schemas/ServiceConsumptionInput" + "$ref": "#/components/schemas/StaffGrant" } } }, "required": [ - "actual_minutes" + "count", + "next", + "previous", + "results" ] }, - "ServiceConsumption": { + "StaffGrantRedeem": { + "type": "object", + "properties": { + "token": { + "type": "string", + "writeOnly": true, + "maxLength": 128, + "minLength": 32 + } + }, + "required": [ + "token" + ] + }, + "StaffInvitation": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "machine_consumable_id": { - "type": "integer", - "readOnly": true - }, - "measurement": { + "email": { "type": "string", "readOnly": true }, - "product_id": { - "type": "integer", - "readOnly": true, - "nullable": true + "makerspace": { + "type": "integer" }, - "label": { + "role": { + "type": "integer" + }, + "role_name": { "type": "string", "readOnly": true }, - "quantity": { + "expires_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "readOnly": true + "format": "date-time" }, - "outcome": { + "accepted_at": { "type": "string", - "readOnly": true + "format": "date-time", + "nullable": true + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "nullable": true }, "created_at": { "type": "string", @@ -101866,915 +125045,1125 @@ }, "required": [ "created_at", + "email", + "expires_at", "id", - "label", - "machine_consumable_id", - "measurement", - "outcome", - "product_id", - "quantity" + "makerspace", + "role", + "role_name" ] }, - "ServiceConsumptionInput": { + "StaffInvitationAccept": { "type": "object", "properties": { - "machine_consumable_id": { - "type": "integer", - "minimum": 1 + "token": { + "type": "string", + "writeOnly": true, + "maxLength": 200, + "minLength": 20 }, - "quantity": { + "makerspace_slug": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "password": { + "type": "string", + "writeOnly": true, + "maxLength": 1024 + }, + "code": { + "type": "string", + "writeOnly": true, + "default": "", + "maxLength": 64 } }, "required": [ - "machine_consumable_id", - "quantity" + "makerspace_slug", + "password", + "token" ] }, - "ServiceFail": { + "StaffInvitationAccepted": { "type": "object", "properties": { - "actual_minutes": { - "type": "integer", - "minimum": 0 - }, - "actual_grams": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - }, - "actual_quantity": { + "detail": { + "type": "string" + } + }, + "required": [ + "detail" + ] + }, + "StaffInvitationCreate": { + "type": "object", + "properties": { + "email": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - }, - "consumptions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ServiceConsumptionInput" - } + "format": "email", + "maxLength": 254 }, - "reason": { - "type": "string" + "role_id": { + "type": "integer", + "minimum": 1 }, - "percent_complete": { + "expires_in_days": { "type": "integer", - "maximum": 100, - "minimum": 0 + "maximum": 30, + "minimum": 1, + "default": 7 } }, "required": [ - "actual_minutes", - "percent_complete", - "reason" + "email", + "role_id" ] }, - "ServiceFile": { + "StaffInvitationDelivery": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "kind": { + "email": { "type": "string", "readOnly": true }, - "original_filename": { + "makerspace": { + "type": "integer" + }, + "role": { + "type": "integer" + }, + "role_name": { "type": "string", "readOnly": true }, - "content_type": { + "expires_at": { "type": "string", - "readOnly": true + "format": "date-time" }, - "size_bytes": { - "type": "integer", - "readOnly": true + "accepted_at": { + "type": "string", + "format": "date-time", + "nullable": true }, - "created_at": { + "revoked_at": { "type": "string", "format": "date-time", - "readOnly": true + "nullable": true }, - "attached_at": { + "created_at": { "type": "string", "format": "date-time", "readOnly": true + }, + "accept_url": { + "type": "string" + }, + "delivery_status": { + "type": "string" } }, "required": [ - "attached_at", - "content_type", + "accept_url", "created_at", + "delivery_status", + "email", + "expires_at", "id", - "kind", - "original_filename", - "size_bytes" + "makerspace", + "role", + "role_name" ] }, - "ServiceFileFinalize": { + "StaffMembership": { "type": "object", "properties": { - "file_id": { + "id": { "type": "integer", - "minimum": 1 - } - }, - "required": [ - "file_id" - ] - }, - "ServiceFileFinalizeResponse": { - "type": "object", - "properties": { - "file_id": { + "readOnly": true + }, + "user": { + "$ref": "#/components/schemas/User" + }, + "makerspace_id": { "type": "integer" - } - }, - "required": [ - "file_id" - ] - }, - "ServiceFilePresign": { - "type": "object", - "properties": { - "filename": { + }, + "makerspace_slug": { "type": "string", - "maxLength": 255 + "pattern": "^[-a-zA-Z0-9_]+$" }, - "content_type": { + "role": { + "$ref": "#/components/schemas/RoleCddEnum" + }, + "created_at": { "type": "string", - "maxLength": 128 + "format": "date-time", + "readOnly": true } }, "required": [ - "content_type", - "filename" + "created_at", + "id", + "makerspace_id", + "makerspace_slug", + "user" ] }, - "ServiceFilePresignResponse": { + "StaffPayment": { "type": "object", "properties": { - "file_id": { - "type": "integer" + "id": { + "type": "integer", + "readOnly": true }, - "upload": { - "$ref": "#/components/schemas/ServiceFileUpload" - } - }, - "required": [ - "file_id", - "upload" - ] - }, - "ServiceFileUpload": { - "type": "object", - "properties": { - "url": { + "subject_type": { + "$ref": "#/components/schemas/SubjectTypeEnum" + }, + "subject_label": { "type": "string", - "format": "uri" + "readOnly": true }, - "method": { - "type": "string" + "status": { + "$ref": "#/components/schemas/Status66aEnum" }, - "fields": { - "type": "object", - "additionalProperties": {} + "amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "headers": { - "type": "object", - "additionalProperties": {} - } - }, - "required": [ - "url" - ] - }, - "ServiceFileUrl": { - "type": "object", - "properties": { - "url": { + "currency": { + "type": "string", + "pattern": "^[a-z]{3}$", + "maxLength": 3 + }, + "checkout_url": { + "type": "string", + "readOnly": true + }, + "online_payment_available": { + "type": "boolean", + "description": "Whether offering to pay online is honest for this charge.\n\nResolved once per makerspace by the view and passed in: doing it per row would\nre-read settings and credentials for every charge in the list. Falls back to a\nlive check only when a caller has not supplied it.", + "readOnly": true + }, + "settlement": { + "allOf": [ + { + "$ref": "#/components/schemas/MemberSettlement" + } + ], + "nullable": true, + "readOnly": true + }, + "created_at": { "type": "string", - "format": "uri" + "format": "date-time", + "readOnly": true } }, "required": [ - "url" + "amount", + "checkout_url", + "created_at", + "currency", + "id", + "online_payment_available", + "settlement", + "subject_label", + "subject_type" ] }, - "ServiceMachine": { + "StaffPaymentSummary": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "name": { + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/Status66aEnum" + } + ], + "readOnly": true + }, + "amount": { "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", "readOnly": true }, - "status": { + "currency": { "type": "string", "readOnly": true } }, "required": [ + "amount", + "currency", "id", - "name", "status" ] }, - "ServiceReject": { - "type": "object", - "properties": { - "reason": { - "type": "string" - } - }, - "required": [ - "reason" - ] - }, - "ServiceRequester": { + "StaffSettlement": { "type": "object", + "description": "The receipt that currently stands for a charge settled at the desk.\n\n`corrected` says an earlier receipt was replaced through amend-settlement; the chain\nitself stays in the ledger and the audit log.", "properties": { "id": { "type": "integer", "readOnly": true }, - "username": { + "method": { + "allOf": [ + { + "$ref": "#/components/schemas/Method2edEnum" + } + ], + "readOnly": true + }, + "reference": { "type": "string", "readOnly": true }, - "display": { + "received_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "recorded_at": { "type": "string", + "format": "date-time", + "readOnly": true + }, + "corrected": { + "type": "boolean", "readOnly": true } }, "required": [ - "display", + "corrected", "id", - "username" + "method", + "received_at", + "recorded_at", + "reference" ] }, - "ServiceStart": { - "type": "object", - "properties": { - "machine_id": { - "type": "integer", - "minimum": 1, - "nullable": true - }, - "estimated_minutes": { - "type": "integer", - "minimum": 0 - }, - "consumable_pool_id": { - "type": "integer", - "minimum": 1 - }, - "planned_grams": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - }, - "planned_quantity": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - } - } + "StageEnum": { + "enum": [ + "requested", + "claimed", + "preflight", + "quiesced", + "db_restoring", + "objects_restoring", + "validating", + "completed", + "restored_quarantined", + "rolling_back", + "failed", + "aborted" + ], + "type": "string", + "description": "* `requested` - Requested\n* `claimed` - Claimed\n* `preflight` - Preflight\n* `quiesced` - Quiesced\n* `db_restoring` - Database restoring\n* `objects_restoring` - Objects restoring\n* `validating` - Validating\n* `completed` - Completed\n* `restored_quarantined` - Restored quarantined\n* `rolling_back` - Rolling back\n* `failed` - Failed\n* `aborted` - Aborted" }, - "SetStatus": { + "State31eEnum": { + "enum": [ + "active", + "expired", + "revoked", + "redeemed" + ], + "type": "string", + "description": "* `active` - active\n* `expired` - expired\n* `revoked` - revoked\n* `redeemed` - redeemed" + }, + "State9efEnum": { + "enum": [ + "requested", + "invited", + "awaiting_vouches", + "awaiting_payment", + "expired", + "canceled", + "active", + "revoked" + ], + "type": "string", + "description": "* `requested` - Requested\n* `invited` - Invited\n* `awaiting_vouches` - Awaiting vouches\n* `awaiting_payment` - Awaiting payment\n* `expired` - Expired\n* `canceled` - Canceled\n* `active` - Active\n* `revoked` - Revoked" + }, + "StationPin": { "type": "object", "properties": { - "status": { + "pin": { "type": "string", - "maxLength": 20 + "writeOnly": true, + "pattern": "^\\d{8}$" } }, "required": [ - "status" + "pin" ] }, - "SetupState": { + "StationReveal": { "type": "object", "properties": { - "profile_set": { - "type": "boolean" - }, - "hours_set": { - "type": "boolean" - }, - "first_item_added": { - "type": "boolean" - }, - "staff_invited": { - "type": "boolean" - }, - "modules_chosen": { - "type": "boolean" + "current_password": { + "type": "string", + "writeOnly": true } }, "required": [ - "first_item_added", - "hours_set", - "modules_chosen", - "profile_set", - "staff_invited" + "current_password" ] }, - "SeverityEnum": { - "enum": [ - "info", - "warning", - "error", - "critical" - ], - "type": "string", - "description": "* `info` - Info\n* `warning` - Warning\n* `error` - Error\n* `critical` - Critical" - }, - "SocialIdentity": { + "StationRevealResponse": { "type": "object", "properties": { - "provider": { + "pin": { "type": "string" }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" + "version": { + "type": "integer" } }, "required": [ - "created_at", - "provider", - "updated_at" + "pin", + "version" ] }, - "SocialLink": { + "StationRotation": { "type": "object", "properties": { - "provider": { - "type": "string", - "maxLength": 64 - }, - "id_token": { - "type": "string", - "writeOnly": true, - "maxLength": 16384 + "pin": { + "type": "string" }, - "nonce": { + "public_token": { "type": "string", - "writeOnly": true, - "maxLength": 512 + "format": "uuid" }, - "client_platform": { - "allOf": [ - { - "$ref": "#/components/schemas/ClientPlatformEnum" - } - ], - "default": "web" + "version": { + "type": "integer" }, - "apple_name": { + "station_url": { "type": "string", - "maxLength": 200 + "format": "uri" } }, "required": [ - "id_token", - "nonce", - "provider" + "pin", + "public_token", + "station_url", + "version" ] }, - "SocialLogin": { + "StationStatus": { "type": "object", "properties": { - "id_token": { - "type": "string", - "writeOnly": true, - "maxLength": 16384 - }, - "nonce": { - "type": "string", - "writeOnly": true, - "maxLength": 512 + "configured": { + "type": "boolean" }, - "surface": { - "$ref": "#/components/schemas/Surface356Enum" + "enabled": { + "type": "boolean" }, - "delivery": { - "$ref": "#/components/schemas/DeliveryEnum" + "public_token": { + "type": "string", + "format": "uuid" }, - "client_platform": { - "$ref": "#/components/schemas/ClientPlatformEnum" + "version": { + "type": "integer" }, - "apple_name": { + "station_url": { "type": "string", - "maxLength": 200 + "format": "uri" }, - "challenge": { + "rotated_at": { "type": "string", - "writeOnly": true, - "maxLength": 512 - }, - "attestation": { - "writeOnly": true + "format": "date-time" } }, "required": [ - "client_platform", - "delivery", - "id_token", - "nonce", - "surface" + "configured" ] }, - "SocialLoginResponse": { + "Status00eEnum": { + "enum": [ + "ok", + "warn", + "error" + ], + "type": "string", + "description": "* `ok` - ok\n* `warn` - warn\n* `error` - error" + }, + "Status2caEnum": { + "enum": [ + "pending", + "confirmed", + "rejected", + "cancelled", + "completed", + "no_show" + ], + "type": "string", + "description": "* `pending` - Pending\n* `confirmed` - Confirmed\n* `rejected` - Rejected\n* `cancelled` - Cancelled\n* `completed` - Completed\n* `no_show` - No-show" + }, + "Status37fEnum": { + "enum": [ + "active", + "revoked" + ], + "type": "string", + "description": "* `active` - Active\n* `revoked` - Revoked" + }, + "Status66aEnum": { + "enum": [ + "pending", + "paid_online", + "paid_offline", + "waived", + "canceled" + ], + "type": "string", + "description": "* `pending` - Pending\n* `paid_online` - Paid online\n* `paid_offline` - Paid offline\n* `waived` - Waived\n* `canceled` - Canceled" + }, + "Status83eEnum": { + "enum": [ + "ok", + "warn", + "error", + "unknown" + ], + "type": "string", + "description": "* `ok` - ok\n* `warn` - warn\n* `error` - error\n* `unknown` - unknown" + }, + "StatusA39Enum": { + "enum": [ + "pending", + "verified", + "failed" + ], + "type": "string", + "description": "* `pending` - Pending\n* `verified` - Verified\n* `failed` - Failed" + }, + "StatusAdeEnum": { + "enum": [ + "draft", + "queued", + "sending", + "sent", + "cancelled", + "failed" + ], + "type": "string", + "description": "* `draft` - Draft\n* `queued` - Queued\n* `sending` - Sending\n* `sent` - Sent\n* `cancelled` - Cancelled\n* `failed` - Failed" + }, + "StatusB9dEnum": { + "enum": [ + "invited", + "accepted", + "declined" + ], + "type": "string", + "description": "* `invited` - Invited\n* `accepted` - Accepted\n* `declined` - Declined" + }, + "StatusDf6Enum": { + "enum": [ + "pending_approval", + "registered", + "waitlisted", + "rejected", + "cancelled", + "attended" + ], + "type": "string", + "description": "* `pending_approval` - Pending approval\n* `registered` - Registered\n* `waitlisted` - Waitlisted\n* `rejected` - Rejected\n* `cancelled` - Cancelled\n* `attended` - Attended" + }, + "StatusE1dEnum": { + "enum": [ + "pending", + "running", + "available", + "failed" + ], + "type": "string", + "description": "* `pending` - Pending\n* `running` - Running\n* `available` - Available\n* `failed` - Failed" + }, + "StatusE75Enum": { + "enum": [ + "active", + "suspended" + ], + "type": "string", + "description": "* `active` - Active\n* `suspended` - Suspended" + }, + "StatusE90Enum": { + "enum": [ + "published" + ], + "type": "string", + "description": "* `published` - published" + }, + "StatusE94Enum": { + "enum": [ + "pending", + "approved", + "rejected" + ], + "type": "string", + "description": "* `pending` - Pending\n* `approved` - Approved\n* `rejected` - Rejected" + }, + "StatusFbbEnum": { + "enum": [ + "draft", + "published", + "cancelled", + "completed" + ], + "type": "string", + "description": "* `draft` - Draft\n* `published` - Published\n* `cancelled` - Cancelled\n* `completed` - Completed" + }, + "Statuses219Enum": { + "enum": [ + "pending", + "confirmed" + ], + "type": "string", + "description": "* `pending` - pending\n* `confirmed` - confirmed" + }, + "StatusesDf6Enum": { + "enum": [ + "pending_approval", + "registered", + "waitlisted", + "rejected", + "cancelled", + "attended" + ], + "type": "string", + "description": "* `pending_approval` - Pending approval\n* `registered` - Registered\n* `waitlisted` - Waitlisted\n* `rejected` - Rejected\n* `cancelled` - Cancelled\n* `attended` - Attended" + }, + "StockTransfer": { "type": "object", "properties": { - "access": { - "type": "string" - }, - "refresh": { - "type": "string" + "id": { + "type": "integer", + "readOnly": true }, - "device_grant": { - "type": "object", - "additionalProperties": {} + "makerspace": { + "type": "integer" }, - "user": { - "type": "object", - "additionalProperties": {} + "source_container": { + "type": "integer", + "nullable": true }, - "outcome": { - "type": "string" - } - }, - "required": [ - "access", - "outcome", - "user" - ] - }, - "SocialNonce": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "maxLength": 64 + "destination_container": { + "type": "integer", + "nullable": true }, - "surface": { - "$ref": "#/components/schemas/Surface356Enum" + "source_makerspace": { + "type": "integer", + "nullable": true }, - "delivery": { - "$ref": "#/components/schemas/DeliveryEnum" + "destination_makerspace": { + "type": "integer", + "nullable": true }, - "client_platform": { - "$ref": "#/components/schemas/ClientPlatformEnum" + "created_by": { + "type": "integer", + "nullable": true }, - "challenge": { - "type": "string", - "writeOnly": true, - "maxLength": 512 - } - }, - "required": [ - "client_platform", - "delivery", - "provider", - "surface" - ] - }, - "SocialNonceResponse": { - "type": "object", - "properties": { - "nonce": { + "reason": { "type": "string" }, - "expires_in": { - "type": "integer" - } - }, - "required": [ - "expires_in", - "nonce" - ] - }, - "SpaceImageFinalizeRequest": { - "type": "object", - "properties": { - "object_key": { - "type": "string", - "maxLength": 500 - } - }, - "required": [ - "object_key" - ] - }, - "SpaceImagePresignRequest": { - "type": "object", - "properties": { - "filename": { + "status": { + "$ref": "#/components/schemas/StockTransferStatusEnum" + }, + "created_at": { "type": "string", - "maxLength": 255 + "format": "date-time", + "readOnly": true }, - "content_type": { + "applied_at": { "type": "string", - "maxLength": 100 + "format": "date-time", + "nullable": true + }, + "lines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StockTransferLine" + }, + "readOnly": true } }, "required": [ - "content_type", - "filename" + "created_at", + "id", + "lines", + "makerspace", + "reason" ] }, - "SpaceImagePresignResponse": { + "StockTransferCreate": { "type": "object", "properties": { - "object_key": { + "source_container_id": { + "type": "integer", + "nullable": true + }, + "destination_container_id": { + "type": "integer", + "nullable": true + }, + "destination_makerspace_id": { + "type": "integer", + "nullable": true + }, + "reason": { "type": "string" }, - "upload": { - "$ref": "#/components/schemas/SpaceImageUpload" + "lines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StockTransferLineInput" + } } }, "required": [ - "object_key", - "upload" + "lines", + "reason" ] }, - "SpaceImageUpload": { + "StockTransferLine": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" + "id": { + "type": "integer", + "readOnly": true }, - "method": { - "$ref": "#/components/schemas/SpaceImageUploadMethodEnum" + "product": { + "type": "integer", + "nullable": true }, - "fields": { - "type": "object", - "additionalProperties": {} + "asset": { + "type": "integer", + "nullable": true }, - "headers": { - "type": "object", - "additionalProperties": {} + "quantity": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "from_status": { + "type": "string", + "maxLength": 32 + }, + "to_status": { + "type": "string", + "maxLength": 32 + }, + "notes": { + "type": "string" } }, "required": [ - "url" + "id" ] }, - "SpaceImageUploadMethodEnum": { - "enum": [ - "PUT" - ], - "type": "string", - "description": "* `PUT` - PUT" - }, - "SpacePack": { + "StockTransferLineInput": { "type": "object", "properties": { - "format": { - "$ref": "#/components/schemas/SpacePackFormatEnum" + "product_id": { + "type": "integer" }, - "version": { + "asset_id": { + "type": "integer" + }, + "quantity": { "type": "integer", - "maximum": 1, - "minimum": 1 + "minimum": 1, + "default": 1 }, - "configuration": { - "allOf": [ + "from_status": { + "oneOf": [ { - "$ref": "#/components/schemas/PackConfiguration" + "$ref": "#/components/schemas/InventoryAssetStatusEnum" + }, + { + "$ref": "#/components/schemas/BlankEnum" } - ], - "description": "Configuration-only sections; see docs/SPACE-PACKS.md." + ] }, - "checksum": { - "type": "string", - "maxLength": 64, - "minLength": 64 + "to_status": { + "oneOf": [ + { + "$ref": "#/components/schemas/InventoryAssetStatusEnum" + }, + { + "$ref": "#/components/schemas/BlankEnum" + } + ] + }, + "notes": { + "type": "string" } - }, - "required": [ - "checksum", - "configuration", - "format", - "version" - ] + } }, - "SpacePackFormatEnum": { + "StockTransferStatusEnum": { "enum": [ - "spaceworks-space-pack" + "applied", + "cancelled" ], "type": "string", - "description": "* `spaceworks-space-pack` - spaceworks-space-pack" + "description": "* `applied` - Applied\n* `cancelled` - Cancelled" }, - "StaffBookingInput": { + "Stocktake": { "type": "object", "properties": { - "certification_override_reason": { - "type": "string", - "description": "Requires existing authority over the linked machine type; audited.", - "maxLength": 1000 + "id": { + "type": "integer", + "readOnly": true }, - "starts_at": { + "makerspace": { + "type": "integer" + }, + "container": { + "type": "integer", + "nullable": true + }, + "status": { + "$ref": "#/components/schemas/StocktakeStatusEnum" + }, + "started_by": { + "type": "integer", + "nullable": true + }, + "approved_by": { + "type": "integer", + "nullable": true + }, + "started_at": { "type": "string", "format": "date-time", - "writeOnly": true + "readOnly": true }, - "ends_at": { + "completed_at": { "type": "string", "format": "date-time", - "writeOnly": true + "nullable": true }, - "custom_answers": { - "writeOnly": true, + "approved_at": { + "type": "string", + "format": "date-time", "nullable": true }, - "member_id": { + "notes": { + "type": "string" + }, + "blind": { + "type": "boolean" + }, + "variance_threshold": { "type": "integer", - "maximum": 9223372036854775807, - "minimum": 1, - "format": "int64" + "maximum": 2147483647, + "minimum": 0, + "nullable": true }, - "note": { - "type": "string", - "maxLength": 5000 + "approval_required": { + "type": "boolean", + "readOnly": true + }, + "lines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StocktakeLine" + }, + "readOnly": true } }, "required": [ - "ends_at", - "member_id", - "starts_at" + "approval_required", + "id", + "lines", + "makerspace", + "started_at" ] }, - "StaffInvitation": { + "StocktakeCreate": { + "type": "object", + "properties": { + "blind": { + "type": "boolean" + }, + "container_id": { + "type": "integer", + "nullable": true + }, + "notes": { + "type": "string" + } + } + }, + "StocktakeLine": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "email": { - "type": "string", - "readOnly": true + "product": { + "type": "integer", + "nullable": true }, - "makerspace": { - "type": "integer" + "asset": { + "type": "integer", + "nullable": true }, - "role": { - "type": "integer" + "container": { + "type": "integer", + "nullable": true }, - "role_name": { - "type": "string", - "readOnly": true + "expected_quantity": { + "type": "integer", + "readOnly": true, + "nullable": true }, - "expires_at": { - "type": "string", - "format": "date-time" + "counted_quantity": { + "type": "integer", + "maximum": 2147483647, + "minimum": -2147483648 }, - "accepted_at": { - "type": "string", - "format": "date-time", + "variance_quantity": { + "type": "integer", + "readOnly": true, "nullable": true }, - "revoked_at": { - "type": "string", - "format": "date-time", - "nullable": true + "condition": { + "$ref": "#/components/schemas/ConditionEnum" }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "notes": { + "type": "string" } }, "required": [ - "created_at", - "email", - "expires_at", + "expected_quantity", "id", - "makerspace", - "role", - "role_name" + "variance_quantity" ] }, - "StaffInvitationAccept": { + "StocktakeLineInput": { "type": "object", "properties": { - "token": { - "type": "string", - "writeOnly": true, - "maxLength": 200, - "minLength": 20 + "product_id": { + "type": "integer" }, - "makerspace_slug": { - "type": "string", - "pattern": "^[-a-zA-Z0-9_]+$" + "asset_id": { + "type": "integer" }, - "password": { - "type": "string", - "writeOnly": true, - "maxLength": 1024 + "container_id": { + "type": "integer", + "nullable": true }, - "code": { - "type": "string", - "writeOnly": true, - "default": "", - "maxLength": 64 - } - }, - "required": [ - "makerspace_slug", - "password", - "token" - ] - }, - "StaffInvitationAccepted": { - "type": "object", - "properties": { - "detail": { + "counted_quantity": { + "type": "integer", + "minimum": 0 + }, + "condition": { + "allOf": [ + { + "$ref": "#/components/schemas/ConditionEnum" + } + ], + "default": "available" + }, + "notes": { "type": "string" } }, "required": [ - "detail" + "counted_quantity" ] }, - "StaffInvitationCreate": { + "StocktakeScanInput": { "type": "object", "properties": { - "email": { + "payload": { "type": "string", - "format": "email", - "maxLength": 254 - }, - "role_id": { - "type": "integer", - "minimum": 1 - }, - "expires_in_days": { - "type": "integer", - "maximum": 30, - "minimum": 1, - "default": 7 + "maxLength": 64 } }, "required": [ - "email", - "role_id" + "payload" ] }, - "StaffInvitationDelivery": { + "StocktakeScanResult": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "email": { - "type": "string", - "readOnly": true + "type": { + "$ref": "#/components/schemas/StocktakeScanResultTypeEnum" }, - "makerspace": { + "container_id": { "type": "integer" }, - "role": { + "asset_id": { "type": "integer" }, - "role_name": { - "type": "string", - "readOnly": true + "product_id": { + "type": "integer" }, - "expires_at": { - "type": "string", - "format": "date-time" + "label": { + "type": "string" }, - "accepted_at": { - "type": "string", - "format": "date-time", - "nullable": true + "code": { + "type": "string" }, - "revoked_at": { - "type": "string", - "format": "date-time", - "nullable": true + "asset_tag": { + "type": "string" }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "product": { + "type": "string" }, - "accept_url": { + "name": { "type": "string" }, - "delivery_status": { + "status": { "type": "string" } }, "required": [ - "accept_url", - "created_at", - "delivery_status", - "email", - "expires_at", - "id", - "makerspace", - "role", - "role_name" + "type" + ] + }, + "StocktakeScanResultTypeEnum": { + "enum": [ + "box", + "asset", + "product" + ], + "type": "string", + "description": "* `box` - box\n* `asset` - asset\n* `product` - product" + }, + "StocktakeStatusEnum": { + "enum": [ + "draft", + "counting", + "completed", + "approved", + "applied", + "cancelled" + ], + "type": "string", + "description": "* `draft` - Draft\n* `counting` - Counting\n* `completed` - Completed\n* `approved` - Approved\n* `applied` - Applied\n* `cancelled` - Cancelled" + }, + "StrategyEnum": { + "enum": [ + "sum", + "group_sum", + "row_union", + "weighted_rate", + "global_rank", + "distinct_person" + ], + "type": "string", + "description": "* `sum` - sum\n* `group_sum` - group_sum\n* `row_union` - row_union\n* `weighted_rate` - weighted_rate\n* `global_rank` - global_rank\n* `distinct_person` - distinct_person" + }, + "StreamEnum": { + "enum": [ + "hardware", + "printing", + "events", + "bookings", + "maintenance", + "membership", + "devices", + "payments", + "stock", + "certifications" + ], + "type": "string", + "description": "* `hardware` - Hardware\n* `printing` - Printing\n* `events` - Events\n* `bookings` - Bookings\n* `maintenance` - Maintenance\n* `membership` - Membership\n* `devices` - Devices\n* `payments` - Payments\n* `stock` - Stock and procurement\n* `certifications` - Certifications" + }, + "StripeConnectOnboarding": { + "type": "object", + "properties": { + "authorize_url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "authorize_url" ] }, - "StaffMembership": { + "StripeMobilePaymentIntentResponse": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "user": { - "$ref": "#/components/schemas/User" + "vendor": { + "$ref": "#/components/schemas/StripeMobilePaymentIntentResponseVendorEnum" }, - "makerspace_id": { + "payment_id": { "type": "integer" }, - "makerspace_slug": { - "type": "string", - "pattern": "^[-a-zA-Z0-9_]+$" + "client_secret": { + "type": "string" }, - "role": { - "$ref": "#/components/schemas/Role827Enum" + "publishable_key": { + "type": "string" }, - "created_at": { + "customer_id": { "type": "string", - "format": "date-time", - "readOnly": true + "nullable": true + }, + "ephemeral_key": { + "type": "string", + "nullable": true } }, "required": [ - "created_at", - "id", - "makerspace_id", - "makerspace_slug", - "user" + "client_secret", + "payment_id", + "publishable_key", + "vendor" ] }, - "StaffPayment": { + "StripeMobilePaymentIntentResponseVendorEnum": { + "enum": [ + "stripe" + ], + "type": "string", + "description": "* `stripe` - stripe" + }, + "SubdomainRequest": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "subject_type": { - "$ref": "#/components/schemas/SubjectTypeEnum" - }, - "subject_label": { + "requested_label": { "type": "string", - "readOnly": true + "maxLength": 63 }, "status": { - "$ref": "#/components/schemas/Status66aEnum" - }, - "amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - }, - "currency": { - "type": "string", - "pattern": "^[a-z]{3}$", - "maxLength": 3 - }, - "checkout_url": { - "type": "string", - "readOnly": true - }, - "online_payment_available": { - "type": "boolean", - "description": "Whether offering to pay online is honest for this charge.\n\nResolved once per makerspace by the view and passed in: doing it per row would\nre-read settings and credentials for every charge in the list. Falls back to a\nlive check only when a caller has not supplied it.", - "readOnly": true - }, - "settlement": { "allOf": [ { - "$ref": "#/components/schemas/MemberSettlement" + "$ref": "#/components/schemas/StatusE94Enum" } ], - "nullable": true, "readOnly": true }, + "note": { + "type": "string", + "readOnly": true + }, + "decided_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, "created_at": { "type": "string", "format": "date-time", @@ -102782,896 +126171,871 @@ } }, "required": [ - "amount", - "checkout_url", "created_at", - "currency", + "decided_at", "id", - "online_payment_available", - "settlement", - "subject_label", - "subject_type" + "note", + "requested_label", + "status" ] }, - "StaffPaymentSummary": { + "SubdomainRequestError": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/Status66aEnum" - } - ], - "readOnly": true - }, - "amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "readOnly": true - }, - "currency": { - "type": "string", - "readOnly": true + "detail": { + "type": "string" } }, "required": [ - "amount", - "currency", - "id", - "status" + "detail" ] }, - "StaffSettlement": { + "SubjectExport": { "type": "object", - "description": "The receipt that currently stands for a charge settled at the desk.\n\n`corrected` says an earlier receipt was replaced through amend-settlement; the chain\nitself stays in the ledger and the audit log.", "properties": { "id": { - "type": "integer", + "type": "string", + "format": "uuid", "readOnly": true }, - "method": { + "status": { "allOf": [ { - "$ref": "#/components/schemas/Method2edEnum" + "$ref": "#/components/schemas/StatusE1dEnum" } ], "readOnly": true }, - "reference": { + "failure_code": { + "readOnly": true, + "oneOf": [ + { + "$ref": "#/components/schemas/FailureCodeEnum" + }, + { + "$ref": "#/components/schemas/BlankEnum" + } + ] + }, + "failure_detail": { "type": "string", "readOnly": true }, - "received_at": { + "expires_at": { "type": "string", "format": "date-time", "readOnly": true }, - "recorded_at": { + "created_at": { "type": "string", "format": "date-time", "readOnly": true }, - "corrected": { - "type": "boolean", + "completed_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "manifest": { "readOnly": true } }, "required": [ - "corrected", + "completed_at", + "created_at", + "expires_at", + "failure_code", + "failure_detail", "id", - "method", - "received_at", - "recorded_at", - "reference" + "manifest", + "status" ] }, - "StageEnum": { - "enum": [ - "requested", - "claimed", - "preflight", - "quiesced", - "db_restoring", - "objects_restoring", - "validating", - "completed", - "restored_quarantined", - "rolling_back", - "failed", - "aborted" - ], - "type": "string", - "description": "* `requested` - Requested\n* `claimed` - Claimed\n* `preflight` - Preflight\n* `quiesced` - Quiesced\n* `db_restoring` - Database restoring\n* `objects_restoring` - Objects restoring\n* `validating` - Validating\n* `completed` - Completed\n* `restored_quarantined` - Restored quarantined\n* `rolling_back` - Rolling back\n* `failed` - Failed\n* `aborted` - Aborted" - }, - "State31eEnum": { - "enum": [ - "active", - "expired", - "revoked", - "redeemed" - ], - "type": "string", - "description": "* `active` - active\n* `expired` - expired\n* `revoked` - revoked\n* `redeemed` - redeemed" - }, - "State9efEnum": { - "enum": [ - "requested", - "invited", - "awaiting_vouches", - "awaiting_payment", - "expired", - "canceled", - "active", - "revoked" - ], - "type": "string", - "description": "* `requested` - Requested\n* `invited` - Invited\n* `awaiting_vouches` - Awaiting vouches\n* `awaiting_payment` - Awaiting payment\n* `expired` - Expired\n* `canceled` - Canceled\n* `active` - Active\n* `revoked` - Revoked" - }, - "StationPin": { + "SubjectExportList": { "type": "object", "properties": { - "pin": { + "count": { + "type": "integer" + }, + "next": { "type": "string", - "writeOnly": true, - "pattern": "^\\d{8}$" - } - }, - "required": [ - "pin" - ] - }, - "StationReveal": { - "type": "object", - "properties": { - "current_password": { + "format": "uri", + "nullable": true + }, + "previous": { "type": "string", - "writeOnly": true + "format": "uri", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SubjectExport" + } } }, "required": [ - "current_password" + "count", + "next", + "previous", + "results" ] }, - "StationRevealResponse": { + "SubjectTypeEnum": { + "enum": [ + "machine_service_request", + "booking", + "event_registration", + "makerspace_membership", + "membership_term", + "membership_request", + "loan_deposit", + "loan_late_fee" + ], + "type": "string", + "description": "* `machine_service_request` - Machine service request\n* `booking` - Booking\n* `event_registration` - Event registration\n* `makerspace_membership` - Makerspace membership\n* `membership_term` - Membership renewal\n* `membership_request` - Membership admission\n* `loan_deposit` - Loan deposit\n* `loan_late_fee` - Loan late fee" + }, + "Suppression": { "type": "object", "properties": { - "pin": { - "type": "string" + "id": { + "type": "integer", + "readOnly": true }, - "version": { - "type": "integer" + "address_hash": { + "type": "string", + "readOnly": true + }, + "reason": { + "allOf": [ + { + "$ref": "#/components/schemas/Reason870Enum" + } + ], + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true } }, "required": [ - "pin", - "version" + "address_hash", + "created_at", + "id", + "reason" ] }, - "StationRotation": { + "SuppressionPage": { "type": "object", "properties": { - "pin": { - "type": "string" + "count": { + "type": "integer" }, - "public_token": { + "next": { "type": "string", - "format": "uuid" - }, - "version": { - "type": "integer" + "format": "uri", + "nullable": true }, - "station_url": { + "previous": { "type": "string", - "format": "uri" + "format": "uri", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Suppression" + } } }, "required": [ - "pin", - "public_token", - "station_url", - "version" + "count", + "next", + "previous", + "results" ] }, - "StationStatus": { + "SuppressionWrite": { "type": "object", "properties": { - "configured": { - "type": "boolean" - }, - "enabled": { - "type": "boolean" - }, - "public_token": { - "type": "string", - "format": "uuid" - }, - "version": { - "type": "integer" - }, - "station_url": { + "address": { "type": "string", - "format": "uri" + "format": "email", + "writeOnly": true, + "maxLength": 254 }, - "rotated_at": { - "type": "string", - "format": "date-time" + "reason": { + "$ref": "#/components/schemas/Reason870Enum" } }, "required": [ - "configured" + "address", + "reason" ] }, - "Status00eEnum": { - "enum": [ - "ok", - "warn", - "error" - ], - "type": "string", - "description": "* `ok` - ok\n* `warn` - warn\n* `error` - error" - }, - "Status2caEnum": { - "enum": [ - "pending", - "confirmed", - "rejected", - "cancelled", - "completed", - "no_show" - ], - "type": "string", - "description": "* `pending` - Pending\n* `confirmed` - Confirmed\n* `rejected` - Rejected\n* `cancelled` - Cancelled\n* `completed` - Completed\n* `no_show` - No-show" - }, - "Status37fEnum": { - "enum": [ - "active", - "revoked" - ], - "type": "string", - "description": "* `active` - Active\n* `revoked` - Revoked" - }, - "Status66aEnum": { - "enum": [ - "pending", - "paid_online", - "paid_offline", - "waived", - "canceled" - ], - "type": "string", - "description": "* `pending` - Pending\n* `paid_online` - Paid online\n* `paid_offline` - Paid offline\n* `waived` - Waived\n* `canceled` - Canceled" - }, - "Status83eEnum": { - "enum": [ - "ok", - "warn", - "error", - "unknown" - ], - "type": "string", - "description": "* `ok` - ok\n* `warn` - warn\n* `error` - error\n* `unknown` - unknown" - }, - "StatusA39Enum": { - "enum": [ - "pending", - "verified", - "failed" - ], - "type": "string", - "description": "* `pending` - Pending\n* `verified` - Verified\n* `failed` - Failed" - }, - "StatusB9dEnum": { - "enum": [ - "invited", - "accepted", - "declined" - ], - "type": "string", - "description": "* `invited` - Invited\n* `accepted` - Accepted\n* `declined` - Declined" - }, - "StatusE1dEnum": { - "enum": [ - "pending", - "running", - "available", - "failed" - ], - "type": "string", - "description": "* `pending` - Pending\n* `running` - Running\n* `available` - Available\n* `failed` - Failed" - }, - "StatusE90Enum": { - "enum": [ - "published" - ], - "type": "string", - "description": "* `published` - published" - }, - "StatusE94Enum": { + "Surface356Enum": { "enum": [ - "pending", - "approved", - "rejected" + "member", + "staff" ], "type": "string", - "description": "* `pending` - Pending\n* `approved` - Approved\n* `rejected` - Rejected" + "description": "* `member` - Member\n* `staff` - Staff" }, - "StatusFbbEnum": { + "Surface817Enum": { "enum": [ - "draft", - "published", - "cancelled", - "completed" + "member", + "staff" ], "type": "string", - "description": "* `draft` - Draft\n* `published` - Published\n* `cancelled` - Cancelled\n* `completed` - Completed" + "description": "* `member` - member\n* `staff` - staff" }, - "StockTransfer": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "makerspace": { - "type": "integer" - }, - "source_container": { - "type": "integer", - "nullable": true - }, - "destination_container": { - "type": "integer", - "nullable": true - }, - "source_makerspace": { - "type": "integer", - "nullable": true - }, - "destination_makerspace": { - "type": "integer", - "nullable": true - }, - "created_by": { - "type": "integer", - "nullable": true - }, - "reason": { - "type": "string" - }, - "status": { - "$ref": "#/components/schemas/StockTransferStatusEnum" - }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "applied_at": { - "type": "string", - "format": "date-time", - "nullable": true + "TakenItemsReport": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "lines": { + "typed_rows": { "type": "array", "items": { - "$ref": "#/components/schemas/StockTransferLine" - }, - "readOnly": true + "$ref": "#/components/schemas/TakenItemsReportRow" + } } }, "required": [ - "created_at", - "id", - "lines", - "makerspace", - "reason" + "rows", + "typed_rows" ] }, - "StockTransferCreate": { + "TakenItemsReportRow": { "type": "object", "properties": { - "source_container_id": { - "type": "integer", - "nullable": true - }, - "destination_container_id": { - "type": "integer", - "nullable": true - }, - "destination_makerspace_id": { - "type": "integer", - "nullable": true + "makerspace_id": { + "type": "integer" }, - "reason": { + "product": { "type": "string" }, - "lines": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StockTransferLineInput" - } + "issued_quantity": { + "type": "integer" } }, "required": [ - "lines", - "reason" + "issued_quantity", + "product" ] }, - "StockTransferLine": { + "TargetEnum": { + "enum": [ + "spool", + "printer" + ], + "type": "string", + "description": "* `spool` - spool\n* `printer` - printer" + }, + "TelegramTestAlert": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "product": { - "type": "integer", - "nullable": true - }, - "asset": { - "type": "integer", - "nullable": true - }, - "quantity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "from_status": { - "type": "string", - "maxLength": 32 + "makerspace_id": { + "type": "integer" }, - "to_status": { + "message": { "type": "string", - "maxLength": 32 - }, - "notes": { - "type": "string" + "default": "Space Works test alert." } }, "required": [ - "id" + "makerspace_id" ] }, - "StockTransferLineInput": { + "TelegramWebhook": { "type": "object", "properties": { - "product_id": { - "type": "integer" - }, - "asset_id": { + "update_id": { "type": "integer" }, - "quantity": { - "type": "integer", - "minimum": 1, - "default": 1 - }, - "from_status": { - "oneOf": [ - { - "$ref": "#/components/schemas/InventoryAssetStatusEnum" - }, - { - "$ref": "#/components/schemas/BlankEnum" - } - ] + "callback_query": { + "type": "object", + "additionalProperties": {} + } + } + }, + "Telemetry": { + "type": "object", + "properties": { + "external_job_id": { + "type": "string", + "maxLength": 120 }, - "to_status": { - "oneOf": [ - { - "$ref": "#/components/schemas/InventoryAssetStatusEnum" - }, - { - "$ref": "#/components/schemas/BlankEnum" - } - ] + "telemetry_state": { + "$ref": "#/components/schemas/TelemetryStateEnum" }, - "notes": { - "type": "string" + "progress_percent": { + "type": "integer", + "maximum": 100, + "minimum": 0 } - } + }, + "required": [ + "external_job_id", + "progress_percent", + "telemetry_state" + ] }, - "StockTransferStatusEnum": { + "TelemetryStateEnum": { "enum": [ - "applied", - "cancelled" + "idle", + "running", + "paused" ], "type": "string", - "description": "* `applied` - Applied\n* `cancelled` - Cancelled" + "description": "* `idle` - idle\n* `running` - running\n* `paused` - paused" }, - "Stocktake": { + "TemplateEnum": { + "enum": [ + "quick_pulse", + "event_review", + "session_speaker" + ], + "type": "string", + "description": "* `quick_pulse` - quick_pulse\n* `event_review` - event_review\n* `session_speaker` - session_speaker" + }, + "TenantBootstrap": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, "makerspace": { - "type": "integer" + "$ref": "#/components/schemas/TenantBootstrapMakerspace" }, - "container": { - "type": "integer", - "nullable": true + "frontend": { + "$ref": "#/components/schemas/TenantBootstrapFrontend" }, - "status": { - "$ref": "#/components/schemas/StocktakeStatusEnum" + "modules": { + "type": "array", + "items": { + "type": "string" + } }, - "started_by": { - "type": "integer", - "nullable": true + "edition": { + "type": "string" }, - "approved_by": { - "type": "integer", - "nullable": true + "features": { + "type": "array", + "items": { + "type": "string" + } }, - "started_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "workflows": { + "type": "array", + "items": { + "type": "string" + } }, - "completed_at": { - "type": "string", - "format": "date-time", - "nullable": true + "theme": {}, + "branding": {}, + "email_enabled": { + "type": "boolean" }, - "approved_at": { - "type": "string", - "format": "date-time", - "nullable": true + "messaging": { + "$ref": "#/components/schemas/TenantMessagingAvailability" }, - "notes": { + "public_api": { + "$ref": "#/components/schemas/TenantBootstrapPublicApi" + } + }, + "required": [ + "branding", + "edition", + "email_enabled", + "features", + "frontend", + "makerspace", + "messaging", + "modules", + "public_api", + "theme", + "workflows" + ] + }, + "TenantBootstrapFrontend": { + "type": "object", + "properties": { + "type": { "type": "string" }, - "blind": { - "type": "boolean" - }, - "variance_threshold": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0, - "nullable": true - }, - "approval_required": { - "type": "boolean", - "readOnly": true + "hostname": { + "type": "string" }, - "lines": { + "allowed_origins": { "type": "array", "items": { - "$ref": "#/components/schemas/StocktakeLine" - }, - "readOnly": true + "type": "string" + } } }, "required": [ - "approval_required", - "id", - "lines", - "makerspace", - "started_at" + "allowed_origins", + "hostname", + "type" ] }, - "StocktakeCreate": { + "TenantBootstrapMakerspace": { "type": "object", "properties": { - "blind": { - "type": "boolean" + "id": { + "type": "integer" }, - "container_id": { - "type": "integer", - "nullable": true + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "public_code": { + "type": "string" + }, + "location": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "opening_hours": { + "oneOf": [ + { + "type": "object", + "maxProperties": 0 + }, + { + "type": "object", + "required": [ + "weekly", + "exceptions" + ], + "additionalProperties": false, + "properties": { + "weekly": { + "oneOf": [ + { + "type": "object", + "maxProperties": 0 + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ], + "properties": { + "mon": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "tue": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "wed": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "thu": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "fri": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "sat": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "sun": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + } + } + } + ] + }, + "exceptions": { + "type": "object", + "maxProperties": 366, + "additionalProperties": { + "type": "array", + "maxItems": 24, + "items": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "description": "YYYY-MM-DD keys; each value replaces that date's weekly hours." + } + } + } + ], + "description": "Empty object means always open. Empty weekly hours mean always open except dated overrides. Empty interval lists mean closed. Sorted intervals use local wall time; split overnight hours across days." }, - "notes": { - "type": "string" - } - } - }, - "StocktakeLine": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "readOnly": true + "map_url": { + "oneOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "string", + "maxLength": 0 + } + ] }, - "product": { - "type": "integer", - "nullable": true + "geofence_enabled": { + "type": "boolean" }, - "asset": { - "type": "integer", + "logo_url": { + "type": "string", "nullable": true }, - "container": { - "type": "integer", + "cover_image_url": { + "type": "string", "nullable": true }, - "expected_quantity": { - "type": "integer", - "readOnly": true, - "nullable": true + "public_stats_enabled": { + "type": "boolean" }, - "counted_quantity": { - "type": "integer", - "maximum": 2147483647, - "minimum": -2147483648 + "public_site_enabled": { + "type": "boolean" }, - "variance_quantity": { - "type": "integer", - "readOnly": true, - "nullable": true + "public_inventory_enabled": { + "type": "boolean" }, - "condition": { - "$ref": "#/components/schemas/ConditionEnum" + "public_landing_page": { + "$ref": "#/components/schemas/TenantBootstrapMakerspacePublicLandingPageEnum" }, - "notes": { - "type": "string" + "membership_policy": { + "$ref": "#/components/schemas/MembershipPolicyEnum" + }, + "request_access": { + "$ref": "#/components/schemas/RequestAccessEnum" } }, "required": [ - "expected_quantity", + "cover_image_url", "id", - "variance_quantity" + "location", + "logo_url", + "map_url", + "membership_policy", + "name", + "opening_hours", + "public_code", + "public_inventory_enabled", + "public_landing_page", + "public_site_enabled", + "public_stats_enabled", + "slug", + "timezone" ] }, - "StocktakeLineInput": { + "TenantBootstrapMakerspacePublicLandingPageEnum": { + "enum": [ + "auto", + "inventory", + "events", + "machines", + "bookings", + "membership" + ], + "type": "string", + "description": "* `auto` - auto\n* `inventory` - inventory\n* `events` - events\n* `machines` - machines\n* `bookings` - bookings\n* `membership` - membership" + }, + "TenantBootstrapPublicApi": { "type": "object", "properties": { - "product_id": { - "type": "integer" - }, - "asset_id": { - "type": "integer" - }, - "container_id": { - "type": "integer", - "nullable": true - }, - "counted_quantity": { - "type": "integer", - "minimum": 0 + "base_url": { + "type": "string" }, - "condition": { - "allOf": [ - { - "$ref": "#/components/schemas/ConditionEnum" - } - ], - "default": "available" + "publishable_key": { + "type": "string" }, - "notes": { + "inventory_path": { "type": "string" } }, "required": [ - "counted_quantity" - ] - }, - "StocktakeScanInput": { - "type": "object", - "properties": { - "payload": { - "type": "string", - "maxLength": 64 - } - }, - "required": [ - "payload" + "base_url", + "inventory_path", + "publishable_key" ] }, - "StocktakeScanResult": { + "TenantMessagingAvailability": { "type": "object", "properties": { - "type": { - "$ref": "#/components/schemas/TypeEnum" - }, - "container_id": { - "type": "integer" - }, - "asset_id": { - "type": "integer" - }, - "product_id": { - "type": "integer" - }, - "label": { - "type": "string" - }, - "code": { - "type": "string" - }, - "asset_tag": { - "type": "string" - }, - "product": { - "type": "string" - }, - "name": { - "type": "string" + "sms": { + "type": "boolean" }, - "status": { - "type": "string" + "whatsapp": { + "type": "boolean" } }, "required": [ - "type" + "sms", + "whatsapp" ] }, - "StocktakeStatusEnum": { - "enum": [ - "draft", - "counting", - "completed", - "approved", - "applied", - "cancelled" - ], - "type": "string", - "description": "* `draft` - Draft\n* `counting` - Counting\n* `completed` - Completed\n* `approved` - Approved\n* `applied` - Applied\n* `cancelled` - Cancelled" - }, - "StrategyEnum": { - "enum": [ - "sum", - "group_sum", - "row_union", - "weighted_rate", - "global_rank", - "distinct_person" - ], - "type": "string", - "description": "* `sum` - sum\n* `group_sum` - group_sum\n* `row_union` - row_union\n* `weighted_rate` - weighted_rate\n* `global_rank` - global_rank\n* `distinct_person` - distinct_person" - }, - "StreamEnum": { + "TextAlignEnum": { "enum": [ - "hardware", - "printing", - "events", - "bookings", - "maintenance", - "membership", - "devices", - "payments", - "stock", - "certifications" + "left", + "center" ], "type": "string", - "description": "* `hardware` - Hardware\n* `printing` - Printing\n* `events` - Events\n* `bookings` - Bookings\n* `maintenance` - Maintenance\n* `membership` - Membership\n* `devices` - Devices\n* `payments` - Payments\n* `stock` - Stock and procurement\n* `certifications` - Certifications" - }, - "StripeConnectOnboarding": { - "type": "object", - "properties": { - "authorize_url": { - "type": "string", - "format": "uri" - } - }, - "required": [ - "authorize_url" - ] + "description": "* `left` - left\n* `center` - center" }, - "SubdomainRequest": { + "Ticket": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "requested_label": { + "name": { "type": "string", - "maxLength": 63 + "readOnly": true }, - "status": { + "amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true + }, + "audience": { "allOf": [ { - "$ref": "#/components/schemas/StatusE94Enum" + "$ref": "#/components/schemas/AudienceBe5Enum" } ], "readOnly": true }, - "note": { - "type": "string", + "capacity": { + "type": "integer", "readOnly": true }, - "decided_at": { + "sales_start_at": { "type": "string", "format": "date-time", "readOnly": true, "nullable": true }, - "created_at": { + "sales_end_at": { "type": "string", "format": "date-time", + "readOnly": true, + "nullable": true + }, + "is_active": { + "type": "boolean", "readOnly": true } }, "required": [ - "created_at", - "decided_at", + "amount", + "audience", + "capacity", "id", - "note", - "requested_label", - "status" - ] - }, - "SubdomainRequestError": { - "type": "object", - "properties": { - "detail": { - "type": "string" - } - }, - "required": [ - "detail" + "is_active", + "name", + "sales_end_at", + "sales_start_at" ] }, - "SubjectExport": { + "TicketInput": { "type": "object", "properties": { - "id": { + "name": { "type": "string", - "format": "uuid", - "readOnly": true - }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/StatusE1dEnum" - } - ], - "readOnly": true - }, - "failure_code": { - "readOnly": true, - "oneOf": [ - { - "$ref": "#/components/schemas/FailureCodeEnum" - }, - { - "$ref": "#/components/schemas/BlankEnum" - } - ] + "maxLength": 100 }, - "failure_detail": { + "amount": { "type": "string", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "expires_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "audience": { + "$ref": "#/components/schemas/AudienceBe5Enum" }, - "created_at": { + "capacity": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "sales_start_at": { "type": "string", "format": "date-time", - "readOnly": true + "nullable": true }, - "completed_at": { + "sales_end_at": { "type": "string", "format": "date-time", - "readOnly": true, "nullable": true - }, - "manifest": { - "readOnly": true } }, "required": [ - "completed_at", - "created_at", - "expires_at", - "failure_code", - "failure_detail", - "id", - "manifest", - "status" + "name" ] }, - "SubjectExportList": { + "TicketList": { "type": "object", "properties": { "count": { @@ -103690,7 +127054,7 @@ "results": { "type": "array", "items": { - "$ref": "#/components/schemas/SubjectExport" + "$ref": "#/components/schemas/Ticket" } } }, @@ -103701,537 +127065,210 @@ "results" ] }, - "SubjectTypeEnum": { - "enum": [ - "machine_service_request", - "booking", - "event_registration", - "makerspace_membership", - "membership_term", - "membership_request", - "loan_deposit", - "loan_late_fee" - ], - "type": "string", - "description": "* `machine_service_request` - Machine service request\n* `booking` - Booking\n* `event_registration` - Event registration\n* `makerspace_membership` - Makerspace membership\n* `membership_term` - Membership renewal\n* `membership_request` - Membership admission\n* `loan_deposit` - Loan deposit\n* `loan_late_fee` - Loan late fee" - }, - "Suppression": { + "TicketPublicFields": { "type": "object", "properties": { - "id": { - "type": "integer", + "status": { + "type": "string", "readOnly": true }, - "address_hash": { + "title": { "type": "string", "readOnly": true }, - "reason": { - "allOf": [ - { - "$ref": "#/components/schemas/Reason870Enum" - } - ], + "description": { + "type": "string", "readOnly": true }, - "created_at": { + "starts_at": { "type": "string", "format": "date-time", "readOnly": true - } - }, - "required": [ - "address_hash", - "created_at", - "id", - "reason" - ] - }, - "SuppressionPage": { - "type": "object", - "properties": { - "count": { - "type": "integer" }, - "next": { + "ends_at": { "type": "string", - "format": "uri", - "nullable": true + "format": "date-time", + "readOnly": true }, - "previous": { + "location": { "type": "string", - "format": "uri", - "nullable": true + "readOnly": true }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Suppression" - } - } - }, - "required": [ - "count", - "next", - "previous", - "results" - ] - }, - "SuppressionWrite": { - "type": "object", - "properties": { - "address": { + "public_token": { "type": "string", - "format": "email", - "writeOnly": true, - "maxLength": 254 - }, - "reason": { - "$ref": "#/components/schemas/Reason870Enum" - } - }, - "required": [ - "address", - "reason" - ] - }, - "Surface356Enum": { - "enum": [ - "member", - "staff" - ], - "type": "string", - "description": "* `member` - Member\n* `staff` - Staff" - }, - "TakenItemsReport": { - "type": "object", - "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "typed_rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TakenItemsReportRow" - } - } - }, - "required": [ - "rows", - "typed_rows" - ] - }, - "TakenItemsReportRow": { - "type": "object", - "properties": { - "makerspace_id": { - "type": "integer" - }, - "product": { - "type": "string" - }, - "issued_quantity": { - "type": "integer" - } - }, - "required": [ - "issued_quantity", - "product" - ] - }, - "TargetEnum": { - "enum": [ - "spool", - "printer" - ], - "type": "string", - "description": "* `spool` - spool\n* `printer` - printer" - }, - "TelegramTestAlert": { - "type": "object", - "properties": { - "makerspace_id": { - "type": "integer" + "format": "uuid", + "readOnly": true }, - "message": { + "timezone_name": { "type": "string", - "default": "Space Works test alert." + "readOnly": true } }, "required": [ - "makerspace_id" - ] - }, - "TelegramWebhook": { - "type": "object", - "properties": { - "update_id": { - "type": "integer" - }, - "callback_query": { - "type": "object", - "additionalProperties": {} - } - } + "description", + "ends_at", + "location", + "public_token", + "starts_at", + "status", + "timezone_name", + "title" + ] }, - "Telemetry": { + "TimelineActor": { "type": "object", "properties": { - "external_job_id": { - "type": "string", - "maxLength": 120 - }, - "telemetry_state": { - "$ref": "#/components/schemas/TelemetryStateEnum" + "username": { + "type": "string" }, - "progress_percent": { - "type": "integer", - "maximum": 100, - "minimum": 0 + "role": { + "type": "string" } }, "required": [ - "external_job_id", - "progress_percent", - "telemetry_state" + "role", + "username" ] }, - "TelemetryStateEnum": { - "enum": [ - "idle", - "running", - "paused" - ], - "type": "string", - "description": "* `idle` - idle\n* `running` - running\n* `paused` - paused" - }, - "TenantBootstrap": { + "TimelineEvent": { "type": "object", "properties": { - "makerspace": { - "$ref": "#/components/schemas/TenantBootstrapMakerspace" - }, - "frontend": { - "$ref": "#/components/schemas/TenantBootstrapFrontend" - }, - "modules": { - "type": "array", - "items": { - "type": "string" - } + "kind": { + "type": "string", + "description": "Event kind. `audit` events carry `detail.action`, `detail.action_label`, projected `detail.meta`, `detail.meta_redacted` and `detail.target_link`." }, - "edition": { - "type": "string" + "at": { + "type": "string", + "format": "date-time" }, - "features": { - "type": "array", - "items": { - "type": "string" - } + "actor": { + "allOf": [ + { + "$ref": "#/components/schemas/TimelineActor" + } + ], + "nullable": true }, - "workflows": { - "type": "array", - "items": { - "type": "string" - } + "detail": { + "type": "object", + "additionalProperties": {} }, - "theme": {}, - "branding": {}, - "email_enabled": { - "type": "boolean" + "evidence_id": { + "type": "integer", + "nullable": true }, - "public_api": { - "$ref": "#/components/schemas/TenantBootstrapPublicApi" + "evidence_retention": { + "allOf": [ + { + "$ref": "#/components/schemas/EvidenceRetention" + } + ], + "nullable": true } }, "required": [ - "branding", - "edition", - "email_enabled", - "features", - "frontend", - "makerspace", - "modules", - "public_api", - "theme", - "workflows" + "actor", + "at", + "detail", + "evidence_id", + "kind" ] }, - "TenantBootstrapFrontend": { + "TimingPolicy": { "type": "object", "properties": { - "type": { - "type": "string" + "quiet_start": { + "type": "string", + "format": "time" }, - "hostname": { - "type": "string" + "quiet_end": { + "type": "string", + "format": "time" }, - "allowed_origins": { + "channels": { "type": "array", "items": { - "type": "string" - } + "$ref": "#/components/schemas/ChannelsEnum" + }, + "maxItems": 9 + }, + "digest_at": { + "type": "string", + "format": "time" + }, + "daily_digest_default": { + "type": "boolean" + }, + "bounce_secret": { + "writeOnly": true, + "oneOf": [ + { + "type": "string", + "maxLength": 200, + "minLength": 32 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "bounce_configured": { + "type": "boolean", + "readOnly": true } }, "required": [ - "allowed_origins", - "hostname", - "type" + "bounce_configured", + "channels", + "digest_at", + "quiet_end", + "quiet_start" ] }, - "TenantBootstrapMakerspace": { + "ToBuyItem": { "type": "object", "properties": { "id": { - "type": "integer" + "type": "integer", + "readOnly": true }, - "name": { - "type": "string" + "makerspace": { + "type": "integer", + "readOnly": true }, - "slug": { - "type": "string" + "kind": { + "allOf": [ + { + "$ref": "#/components/schemas/ToBuyItemKindEnum" + } + ], + "readOnly": true }, - "public_code": { - "type": "string" + "machine_type": { + "type": "integer", + "nullable": true }, - "location": { - "type": "string" + "machine_type_name": { + "type": "string", + "readOnly": true, + "nullable": true }, - "timezone": { - "type": "string" + "name": { + "type": "string", + "maxLength": 200 }, - "opening_hours": { - "oneOf": [ - { - "type": "object", - "maxProperties": 0 - }, - { - "type": "object", - "required": [ - "weekly", - "exceptions" - ], - "additionalProperties": false, - "properties": { - "weekly": { - "oneOf": [ - { - "type": "object", - "maxProperties": 0 - }, - { - "type": "object", - "additionalProperties": false, - "required": [ - "mon", - "tue", - "wed", - "thu", - "fri", - "sat", - "sun" - ], - "properties": { - "mon": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "tue": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "wed": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "thu": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "fri": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "sat": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "sun": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - } - } - } - ] - }, - "exceptions": { - "type": "object", - "maxProperties": 366, - "additionalProperties": { - "type": "array", - "maxItems": 24, - "items": { - "type": "object", - "required": [ - "start", - "end" - ], - "additionalProperties": false, - "properties": { - "start": { - "type": "string", - "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" - }, - "end": { - "type": "string", - "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" - } - } - } - }, - "description": "YYYY-MM-DD keys; each value replaces that date's weekly hours." - } - } - } - ], - "description": "Empty object means always open. Empty weekly hours mean always open except dated overrides. Empty interval lists mean closed. Sorted intervals use local wall time; split overnight hours across days." + "quantity": { + "type": "integer", + "minimum": 1, + "default": 1 }, - "map_url": { + "link": { "oneOf": [ { "type": "string", - "format": "uri" + "format": "uri", + "maxLength": 200 }, { "type": "string", @@ -104239,855 +127276,1219 @@ } ] }, - "geofence_enabled": { + "status": { + "$ref": "#/components/schemas/ToBuyItemStatusEnum" + }, + "estimated_unit_cost": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$", + "nullable": true + }, + "vendor_name": { + "type": "string", + "maxLength": 200 + }, + "actual_unit_cost": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$", + "nullable": true + }, + "purchaser": { + "type": "integer", + "readOnly": true + }, + "purchaser_username": { + "type": "string", + "readOnly": true + }, + "ordered_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "received_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "moved_to_inventory_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "resulting_product": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "resulting_pool": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "resulting_machine": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "source_pool": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "source_product": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "receipts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ToBuyReceipt" + }, + "readOnly": true + }, + "created_by": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_by_username": { + "type": "string", + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "created_at", + "created_by", + "created_by_username", + "id", + "kind", + "machine_type_name", + "makerspace", + "moved_to_inventory_at", + "name", + "ordered_at", + "purchaser", + "purchaser_username", + "receipts", + "received_at", + "resulting_machine", + "resulting_pool", + "resulting_product", + "source_pool", + "source_product", + "updated_at" + ] + }, + "ToBuyItemKindEnum": { + "enum": [ + "hardware", + "printing" + ], + "type": "string", + "description": "* `hardware` - Hardware\n* `printing` - Printing" + }, + "ToBuyItemStatusEnum": { + "enum": [ + "requested", + "approved", + "ordered", + "received", + "cancelled" + ], + "type": "string", + "description": "* `requested` - Requested\n* `approved` - Approved\n* `ordered` - Ordered\n* `received` - Received\n* `cancelled` - Cancelled" + }, + "ToBuyMachineType": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "name": { + "type": "string", + "readOnly": true + } + }, + "required": [ + "id", + "name" + ] + }, + "ToBuyMachineTypeOptions": { + "type": "object", + "description": "Options plus a SERVER-derived answer to \"must the caller pick one?\".\n\nThe console must not infer this from effective actions. A null-`assigned_role`\nlegacy membership is scope-exempt and cannot be expressed that way, and \"holds\nMANAGE_MACHINES\" is not the same question as \"is narrowed by machine scope\" -- a role\nholding MANAGE_MAKERSPACE holds both and is exempt. Deriving it here keeps one answer\non the server, the same reason the dashboard carries `scope_mode`.", + "properties": { + "machine_type_required": { "type": "boolean" }, - "logo_url": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ToBuyMachineType" + } + } + }, + "required": [ + "machine_type_required", + "results" + ] + }, + "ToBuyReceipt": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "created_at": { "type": "string", + "format": "date-time", + "readOnly": true + }, + "uploaded_by": { + "type": "integer", + "readOnly": true, "nullable": true }, - "cover_image_url": { + "uploaded_by_username": { "type": "string", - "nullable": true + "readOnly": true + } + }, + "required": [ + "created_at", + "id", + "uploaded_by", + "uploaded_by_username" + ] + }, + "ToBuyReceiptFinalize": { + "type": "object", + "properties": { + "object_key": { + "type": "string", + "maxLength": 512 + } + }, + "required": [ + "object_key" + ] + }, + "ToBuyReceiptPresign": { + "type": "object", + "properties": { + "filename": { + "type": "string", + "maxLength": 255 }, - "public_stats_enabled": { - "type": "boolean" + "content_type": { + "type": "string", + "maxLength": 100 + } + }, + "required": [ + "content_type", + "filename" + ] + }, + "ToBuyReceiptUploadResponse": { + "type": "object", + "properties": { + "object_key": { + "type": "string" }, - "public_site_enabled": { - "type": "boolean" + "upload": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "object_key", + "upload" + ] + }, + "ToBuyReceiptUrl": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url" + ] + }, + "TopBorrowersReport": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "public_inventory_enabled": { - "type": "boolean" + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TopBorrowersReportRow" + } + } + }, + "required": [ + "rows", + "typed_rows" + ] + }, + "TopBorrowersReportRow": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer" }, - "public_landing_page": { - "$ref": "#/components/schemas/TenantBootstrapMakerspacePublicLandingPageEnum" + "holder": { + "type": "string" }, - "membership_policy": { - "$ref": "#/components/schemas/MembershipPolicyEnum" + "requests": { + "type": "integer" }, - "request_access": { - "$ref": "#/components/schemas/RequestAccessEnum" + "items_borrowed": { + "type": "integer" } }, "required": [ - "cover_image_url", - "id", - "location", - "logo_url", - "map_url", - "membership_policy", - "name", - "opening_hours", - "public_code", - "public_inventory_enabled", - "public_landing_page", - "public_site_enabled", - "public_stats_enabled", - "slug", - "timezone" + "holder", + "items_borrowed", + "requests" ] }, - "TenantBootstrapMakerspacePublicLandingPageEnum": { + "TrackingModeB86Enum": { "enum": [ - "auto", - "inventory", - "events", - "machines", - "bookings", - "membership" + "quantity", + "individual" ], "type": "string", - "description": "* `auto` - auto\n* `inventory` - inventory\n* `events` - events\n* `machines` - machines\n* `bookings` - bookings\n* `membership` - membership" + "description": "* `quantity` - Quantity\n* `individual` - Individual" }, - "TenantBootstrapPublicApi": { + "TypedError": { "type": "object", "properties": { - "base_url": { + "detail": { "type": "string" }, - "publishable_key": { + "code": { "type": "string" + } + }, + "required": [ + "code", + "detail" + ] + }, + "TypedManualUsage": { + "type": "object", + "properties": { + "member": { + "type": "integer", + "minimum": 1, + "nullable": true }, - "inventory_path": { + "started_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "machine_id": { + "type": "integer", + "minimum": 1 + }, + "consumable_pool_id": { + "type": "integer", + "minimum": 1, + "nullable": true + }, + "service_request_id": { + "type": "integer", + "minimum": 1, + "nullable": true + }, + "duration_minutes": { + "type": "integer", + "minimum": 0 + }, + "outcome": { + "$ref": "#/components/schemas/TypedManualUsageOutcomeEnum" + }, + "percent_complete": { + "type": "integer", + "maximum": 100, + "minimum": 0, + "default": 100 + }, + "reason": { "type": "string" + }, + "grams": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "default": "0.00" + }, + "quantity": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "metering_unit": { + "$ref": "#/components/schemas/TypedManualUsageMeteringUnitEnum" + }, + "note": { + "type": "string", + "maxLength": 255 } }, "required": [ - "base_url", - "inventory_path", - "publishable_key" + "duration_minutes", + "machine_id", + "outcome" ] }, - "TextAlignEnum": { + "TypedManualUsageMeteringUnitEnum": { "enum": [ - "left", - "center" + "minutes", + "weight", + "volume", + "length", + "count" ], "type": "string", - "description": "* `left` - left\n* `center` - center" + "description": "* `minutes` - minutes\n* `weight` - weight\n* `volume` - volume\n* `length` - length\n* `count` - count" }, - "Ticket": { + "TypedManualUsageOutcomeEnum": { + "enum": [ + "success", + "failed" + ], + "type": "string", + "description": "* `success` - success\n* `failed` - failed" + }, + "TypedManualUsageResponse": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "name": { + "machine_id": { + "type": "integer", + "readOnly": true + }, + "consumable_pool_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "service_request_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "duration_minutes": { + "type": "integer", + "readOnly": true + }, + "outcome": { "type": "string", "readOnly": true }, - "amount": { + "percent_complete": { + "type": "integer", + "readOnly": true + }, + "reason": { + "type": "string", + "readOnly": true + }, + "consumed_grams": { "type": "string", "format": "decimal", "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", "readOnly": true }, - "audience": { + "metering_unit": { "allOf": [ { - "$ref": "#/components/schemas/AudienceBe5Enum" + "$ref": "#/components/schemas/TypedManualUsageResponseMeteringUnitEnum" } ], "readOnly": true }, - "capacity": { - "type": "integer", + "consumed_quantity": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", "readOnly": true }, - "sales_start_at": { + "hours": { "type": "string", - "format": "date-time", + "format": "decimal", + "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$", + "readOnly": true + }, + "note": { + "type": "string", + "readOnly": true + }, + "member": { + "type": "integer", "readOnly": true, "nullable": true }, - "sales_end_at": { + "started_at": { "type": "string", "format": "date-time", "readOnly": true, "nullable": true }, - "is_active": { - "type": "boolean", + "created_at": { + "type": "string", + "format": "date-time", "readOnly": true } }, "required": [ - "amount", - "audience", - "capacity", + "consumable_pool_id", + "consumed_grams", + "consumed_quantity", + "created_at", + "duration_minutes", + "hours", "id", - "is_active", - "name", - "sales_end_at", - "sales_start_at" + "machine_id", + "member", + "metering_unit", + "note", + "outcome", + "percent_complete", + "reason", + "service_request_id", + "started_at" ] }, - "TicketInput": { + "TypedManualUsageResponseMeteringUnitEnum": { + "enum": [ + "minutes", + "weight", + "volume", + "length", + "count" + ], + "type": "string", + "description": "* `minutes` - Minutes\n* `weight` - Weight\n* `volume` - Volume\n* `length` - Length\n* `count` - Count" + }, + "UnsubscribeResult": { "type": "object", "properties": { - "name": { + "detail": { + "type": "string" + } + }, + "required": [ + "detail" + ] + }, + "UsageResource": { + "type": "object", + "properties": { + "key": { "type": "string", - "maxLength": 100 + "description": "Resource limit key, including daily sms and whatsapp logical messages." }, - "amount": { + "label": { + "type": "string" + }, + "used": { + "type": "integer", + "nullable": true + }, + "limit": { + "type": "integer", + "nullable": true + }, + "unit": { + "$ref": "#/components/schemas/UsageResourceUnitEnum" + } + }, + "required": [ + "key", + "label", + "limit", + "unit", + "used" + ] + }, + "UsageResourceUnitEnum": { + "enum": [ + "bytes", + "count" + ], + "type": "string", + "description": "* `bytes` - bytes\n* `count` - count" + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "username": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "description": "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + "pattern": "^[\\w.@+-]+$", + "maxLength": 150 + }, + "email": { + "title": "Email address", + "oneOf": [ + { + "type": "string", + "format": "email", + "maxLength": 254 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "first_name": { + "type": "string", + "maxLength": 150 + }, + "last_name": { + "type": "string", + "maxLength": 150 }, - "audience": { - "$ref": "#/components/schemas/AudienceBe5Enum" + "role": { + "$ref": "#/components/schemas/UserRoleEnum" }, - "capacity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "access_status": { + "$ref": "#/components/schemas/AccessStatusEnum" }, - "sales_start_at": { + "restriction_reason": { + "type": "string" + }, + "telegram_user_id": { "type": "string", - "format": "date-time", - "nullable": true + "maxLength": 64 }, - "sales_end_at": { + "external_checkin_user_id": { "type": "string", - "format": "date-time", - "nullable": true + "maxLength": 128 + }, + "is_active": { + "type": "boolean", + "title": "Active", + "description": "Designates whether this user should be treated as active. Unselect this instead of deleting accounts." } }, "required": [ - "name" + "id", + "username" ] }, - "TicketList": { + "UserRoleEnum": { + "enum": [ + "superadmin", + "space_manager", + "requester" + ], + "type": "string", + "description": "* `superadmin` - Super Admin\n* `space_manager` - Space Manager\n* `requester` - Requester" + }, + "VerificationAccepted": { "type": "object", "properties": { - "count": { + "id": { "type": "integer" }, - "next": { - "type": "string", - "format": "uri", - "nullable": true + "state": { + "type": "string" }, - "previous": { - "type": "string", - "format": "uri", - "nullable": true + "deduplicated": { + "type": "boolean" }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Ticket" - } + "status_url": { + "type": "string" } }, "required": [ - "count", - "next", - "previous", - "results" + "deduplicated", + "id", + "state", + "status_url" ] }, - "TimelineActor": { + "VerificationCoverage": { "type": "object", "properties": { - "username": { - "type": "string" + "attempt": { + "type": "integer" }, - "role": { - "type": "string" + "chain_status": { + "$ref": "#/components/schemas/ChainStatusEnum" + }, + "anchor_status": { + "$ref": "#/components/schemas/AnchorStatusEnum" + }, + "row_count": { + "type": "integer" + }, + "mac_valid_count": { + "type": "integer" + }, + "unattested_count": { + "type": "integer" + }, + "mac_invalid_count": { + "type": "integer" + }, + "mac_unavailable_count": { + "type": "integer" + }, + "unbatched_count": { + "type": "integer" + }, + "local_batch_verified_count": { + "type": "integer" + }, + "batch_invalid_count": { + "type": "integer" + }, + "batch_unavailable_count": { + "type": "integer" + }, + "externally_anchored_count": { + "type": "integer" } }, "required": [ - "role", - "username" + "anchor_status", + "attempt", + "batch_invalid_count", + "batch_unavailable_count", + "chain_status", + "externally_anchored_count", + "local_batch_verified_count", + "mac_invalid_count", + "mac_unavailable_count", + "mac_valid_count", + "row_count", + "unattested_count", + "unbatched_count" ] }, - "TimelineEvent": { + "VerificationFilters": { "type": "object", "properties": { - "kind": { - "type": "string", - "description": "Event kind. `audit` events carry `detail.action`, `detail.action_label`, projected `detail.meta`, `detail.meta_redacted` and `detail.target_link`." + "action": { + "type": "string" }, - "at": { - "type": "string", - "format": "date-time" + "target_type": { + "type": "string" + }, + "target_id": { + "type": "string" }, "actor": { - "allOf": [ - { - "$ref": "#/components/schemas/TimelineActor" - } - ], + "type": "integer", "nullable": true }, - "detail": { - "type": "object", - "additionalProperties": {} - }, - "evidence_id": { - "type": "integer", + "start": { + "type": "string", + "format": "date-time", "nullable": true }, - "evidence_retention": { - "allOf": [ - { - "$ref": "#/components/schemas/EvidenceRetention" - } - ], + "end": { + "type": "string", + "format": "date-time", "nullable": true } }, "required": [ + "action", "actor", - "at", - "detail", - "evidence_id", - "kind" + "end", + "start", + "target_id", + "target_type" ] }, - "TimingPolicy": { + "VerificationReport": { "type": "object", "properties": { - "quiet_start": { - "type": "string", - "format": "time" + "format_version": { + "type": "integer" }, - "quiet_end": { - "type": "string", - "format": "time" + "target_makerspace_id": { + "type": "integer" }, - "channels": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ChannelsEnum" - }, - "maxItems": 7 + "imported": { + "type": "object", + "additionalProperties": {} }, - "digest_at": { - "type": "string", - "format": "time" + "resolved": { + "type": "object", + "additionalProperties": {} }, - "daily_digest_default": { - "type": "boolean" + "dropped": { + "type": "object", + "additionalProperties": {} }, - "bounce_secret": { - "writeOnly": true, - "oneOf": [ - { - "type": "string", - "maxLength": 200, - "minLength": 32 - }, - { - "type": "string", - "maxLength": 0 - } - ] + "identities_linked": { + "type": "integer" }, - "bounce_configured": { - "type": "boolean", - "readOnly": true + "identities_created": { + "type": "integer" + }, + "external_references_created": { + "type": "integer" } }, "required": [ - "bounce_configured", - "channels", - "digest_at", - "quiet_end", - "quiet_start" + "dropped", + "external_references_created", + "format_version", + "identities_created", + "identities_linked", + "imported", + "resolved", + "target_makerspace_id" ] }, - "ToBuyItem": { + "VerificationRequest": { "type": "object", + "description": "The audit-list filters, minus ``makerspace`` (it comes from the path).", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "makerspace": { - "type": "integer", - "readOnly": true - }, - "kind": { - "allOf": [ - { - "$ref": "#/components/schemas/ToBuyItemKindEnum" - } - ], - "readOnly": true - }, - "machine_type": { - "type": "integer", - "nullable": true - }, - "machine_type_name": { - "type": "string", - "readOnly": true, - "nullable": true - }, - "name": { - "type": "string", - "maxLength": 200 - }, - "quantity": { - "type": "integer", - "minimum": 1, - "default": 1 - }, - "link": { - "oneOf": [ - { - "type": "string", - "format": "uri", - "maxLength": 200 - }, - { - "type": "string", - "maxLength": 0 - } - ] - }, - "status": { - "$ref": "#/components/schemas/ToBuyItemStatusEnum" - }, - "estimated_unit_cost": { + "action": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$", - "nullable": true + "maxLength": 128 }, - "vendor_name": { + "target_type": { "type": "string", "maxLength": 200 }, - "actual_unit_cost": { + "target_id": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$", - "nullable": true + "maxLength": 100 }, - "purchaser": { + "actor": { "type": "integer", - "readOnly": true + "minimum": 1 }, - "purchaser_username": { + "start": { "type": "string", - "readOnly": true + "format": "date-time" }, - "ordered_at": { + "end": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "format": "date-time" + } + } + }, + "VerificationRun": { + "type": "object", + "properties": { + "id": { + "type": "integer" }, - "received_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "makerspace": { + "type": "integer" }, - "moved_to_inventory_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "state": { + "$ref": "#/components/schemas/VerificationRunStateEnum" }, - "resulting_product": { - "type": "integer", - "readOnly": true, - "nullable": true + "terminal": { + "type": "boolean" }, - "resulting_pool": { - "type": "integer", - "readOnly": true, - "nullable": true + "snapshot_max_id": { + "type": "integer" }, - "resulting_machine": { - "type": "integer", - "readOnly": true, - "nullable": true + "filters": { + "$ref": "#/components/schemas/VerificationFilters" }, - "source_pool": { - "type": "integer", - "readOnly": true, - "nullable": true + "coverage": { + "$ref": "#/components/schemas/VerificationCoverage" }, - "source_product": { - "type": "integer", - "readOnly": true, - "nullable": true + "created_at": { + "type": "string", + "format": "date-time" }, - "receipts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ToBuyReceipt" - }, - "readOnly": true + "updated_at": { + "type": "string", + "format": "date-time" }, - "created_by": { - "type": "integer", - "readOnly": true, - "nullable": true + "status_url": { + "type": "string" }, - "created_by_username": { + "failure_code": { "type": "string", - "readOnly": true + "description": "Superuser only: closed operational failure code." }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "first_audit_log_id": { + "type": "integer", + "nullable": true, + "description": "Superuser only diagnostic." }, - "updated_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "first_batch_seq": { + "type": "integer", + "nullable": true, + "description": "Superuser only diagnostic." } }, "required": [ + "coverage", "created_at", - "created_by", - "created_by_username", + "filters", "id", - "kind", - "machine_type_name", "makerspace", - "moved_to_inventory_at", - "name", - "ordered_at", - "purchaser", - "purchaser_username", - "receipts", - "received_at", - "resulting_machine", - "resulting_pool", - "resulting_product", - "source_pool", - "source_product", + "snapshot_max_id", + "state", + "status_url", + "terminal", "updated_at" ] }, - "ToBuyItemKindEnum": { - "enum": [ - "hardware", - "printing" - ], - "type": "string", - "description": "* `hardware` - Hardware\n* `printing` - Printing" - }, - "ToBuyItemStatusEnum": { - "enum": [ - "requested", - "approved", - "ordered", - "received", - "cancelled" - ], - "type": "string", - "description": "* `requested` - Requested\n* `approved` - Approved\n* `ordered` - Ordered\n* `received` - Received\n* `cancelled` - Cancelled" - }, - "ToBuyMachineType": { + "VerificationRunPage": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true + "count": { + "type": "integer" }, - "name": { + "next": { "type": "string", - "readOnly": true - } - }, - "required": [ - "id", - "name" - ] - }, - "ToBuyMachineTypeOptions": { - "type": "object", - "description": "Options plus a SERVER-derived answer to \"must the caller pick one?\".\n\nThe console must not infer this from effective actions. A null-`assigned_role`\nlegacy membership is scope-exempt and cannot be expressed that way, and \"holds\nMANAGE_MACHINES\" is not the same question as \"is narrowed by machine scope\" -- a role\nholding MANAGE_MAKERSPACE holds both and is exempt. Deriving it here keeps one answer\non the server, the same reason the dashboard carries `scope_mode`.", - "properties": { - "machine_type_required": { - "type": "boolean" + "format": "uri", + "nullable": true + }, + "previous": { + "type": "string", + "format": "uri", + "nullable": true }, "results": { "type": "array", "items": { - "$ref": "#/components/schemas/ToBuyMachineType" + "$ref": "#/components/schemas/VerificationRun" } } }, "required": [ - "machine_type_required", + "count", + "next", + "previous", "results" ] }, - "ToBuyReceipt": { + "VerificationRunStateEnum": { + "enum": [ + "QUEUED", + "RUNNING", + "RETRYING", + "SUCCEEDED", + "INCOMPLETE", + "FAILED", + "TIMED_OUT" + ], + "type": "string", + "description": "* `QUEUED` - Queued\n* `RUNNING` - Running\n* `RETRYING` - Retrying\n* `SUCCEEDED` - Succeeded\n* `INCOMPLETE` - Incomplete\n* `FAILED` - Failed\n* `TIMED_OUT` - Timed out" + }, + "VersionEnum": { + "enum": [ + 1 + ], + "type": "integer", + "description": "* `1` - 1" + }, + "Visibility68aEnum": { + "enum": [ + "draft", + "published" + ], + "type": "string", + "description": "* `draft` - Draft\n* `published` - Published" + }, + "VisibilityE49Enum": { + "enum": [ + "draft", + "published" + ], + "type": "string", + "description": "* `draft` - draft\n* `published` - published" + }, + "Vouch": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true + "voucher_id": { + "type": "integer" + }, + "voucher_name": { + "type": "string" }, "created_at": { "type": "string", "format": "date-time", "readOnly": true }, - "uploaded_by": { - "type": "integer", - "readOnly": true, - "nullable": true - }, - "uploaded_by_username": { + "expires_at": { "type": "string", - "readOnly": true + "format": "date-time" } }, "required": [ "created_at", - "id", - "uploaded_by", - "uploaded_by_username" - ] - }, - "ToBuyReceiptFinalize": { - "type": "object", - "properties": { - "object_key": { - "type": "string", - "maxLength": 512 - } - }, - "required": [ - "object_key" + "expires_at", + "voucher_id", + "voucher_name" ] }, - "ToBuyReceiptPresign": { + "WaiverAcceptResponse": { "type": "object", "properties": { - "filename": { - "type": "string", - "maxLength": 255 + "accepted": { + "type": "boolean" }, - "content_type": { + "version": { "type": "string", - "maxLength": 100 + "nullable": true } }, "required": [ - "content_type", - "filename" + "accepted", + "version" ] }, - "ToBuyReceiptUploadResponse": { + "WaiverPublish": { "type": "object", "properties": { - "object_key": { + "body": { "type": "string" }, - "upload": { - "type": "object", - "additionalProperties": {} - } - }, - "required": [ - "object_key", - "upload" - ] - }, - "ToBuyReceiptUrl": { - "type": "object", - "properties": { - "url": { + "version": { "type": "string", - "format": "uri" - } - }, - "required": [ - "url" - ] - }, - "TopBorrowersReport": { - "type": "object", - "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } + "maxLength": 64 }, - "typed_rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TopBorrowersReportRow" - } + "clear": { + "type": "boolean", + "default": false } - }, - "required": [ - "rows", - "typed_rows" - ] + } }, - "TopBorrowersReportRow": { + "WaiverStateEnum": { + "enum": [ + "not_required", + "current", + "missing" + ], + "type": "string", + "description": "* `not_required` - not_required\n* `current` - current\n* `missing` - missing" + }, + "WalkInAdmissionOutcome": { "type": "object", "properties": { - "makerspace_id": { + "user_id": { "type": "integer" }, - "holder": { + "display_name": { "type": "string" }, - "requests": { - "type": "integer" + "username": { + "type": "string" }, - "items_borrowed": { - "type": "integer" + "admission_request": { + "$ref": "#/components/schemas/AdmissionRequest" } }, "required": [ - "holder", - "items_borrowed", - "requests" + "admission_request", + "display_name", + "user_id", + "username" ] }, - "TrackingModeB86Enum": { - "enum": [ - "quantity", - "individual" - ], - "type": "string", - "description": "* `quantity` - Quantity\n* `individual` - Individual" - }, - "TypeEnum": { - "enum": [ - "box", - "asset", - "product" - ], - "type": "string", - "description": "* `box` - box\n* `asset` - asset\n* `product` - product" + "WalkInAdmissionResult": { + "oneOf": [ + { + "$ref": "#/components/schemas/DirectLoanMember" + }, + { + "$ref": "#/components/schemas/WalkInAdmissionOutcome" + } + ] }, - "TypedError": { + "WalkInMemberCreate": { "type": "object", "properties": { - "detail": { - "type": "string" + "display_name": { + "type": "string", + "maxLength": 200 }, - "code": { - "type": "string" + "email": { + "oneOf": [ + { + "type": "string", + "format": "email", + "default": "" + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "phone": { + "type": "string", + "default": "", + "maxLength": 32 + }, + "application_answers": { + "nullable": true } }, "required": [ - "code", - "detail" + "display_name" ] }, - "TypedManualUsage": { + "Warranty": { "type": "object", "properties": { - "machine_id": { - "type": "integer", - "minimum": 1 - }, - "consumable_pool_id": { + "id": { "type": "integer", - "minimum": 1, - "nullable": true + "readOnly": true }, - "service_request_id": { - "type": "integer", - "minimum": 1, - "nullable": true + "host_kind": { + "type": "string", + "readOnly": true }, - "duration_minutes": { + "host_id": { "type": "integer", - "minimum": 0 + "readOnly": true }, - "outcome": { - "$ref": "#/components/schemas/TypedManualUsageOutcomeEnum" + "host_label": { + "type": "string", + "readOnly": true }, - "percent_complete": { + "asset_id": { "type": "integer", - "maximum": 100, - "minimum": 0, - "default": 100 - }, - "reason": { - "type": "string" - }, - "grams": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "default": "0.00" + "nullable": true, + "readOnly": true }, - "quantity": { + "asset_tag": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - }, - "metering_unit": { - "$ref": "#/components/schemas/TypedManualUsageMeteringUnitEnum" + "nullable": true, + "readOnly": true }, - "note": { + "serial_number": { "type": "string", - "maxLength": 255 - } - }, - "required": [ - "duration_minutes", - "machine_id", - "outcome" - ] - }, - "TypedManualUsageMeteringUnitEnum": { - "enum": [ - "minutes", - "weight", - "volume", - "length", - "count" - ], - "type": "string", - "description": "* `minutes` - minutes\n* `weight` - weight\n* `volume` - volume\n* `length` - length\n* `count` - count" - }, - "TypedManualUsageOutcomeEnum": { - "enum": [ - "success", - "failed" - ], - "type": "string", - "description": "* `success` - success\n* `failed` - failed" - }, - "TypedManualUsageResponse": { - "type": "object", - "properties": { - "id": { - "type": "integer", + "nullable": true, "readOnly": true }, "machine_id": { "type": "integer", + "nullable": true, "readOnly": true }, - "consumable_pool_id": { - "type": "integer", + "machine_name": { + "type": "string", + "nullable": true, + "readOnly": true + }, + "purchased_on": { + "type": "string", + "format": "date", "readOnly": true, "nullable": true }, - "service_request_id": { - "type": "integer", + "warranty_expires_on": { + "type": "string", + "format": "date", "readOnly": true, "nullable": true }, - "duration_minutes": { - "type": "integer", - "readOnly": true - }, - "outcome": { + "vendor_name": { "type": "string", "readOnly": true }, - "percent_complete": { - "type": "integer", - "readOnly": true - }, - "reason": { + "vendor_contact": { "type": "string", "readOnly": true }, - "consumed_grams": { + "status": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", "readOnly": true }, - "metering_unit": { - "allOf": [ - { - "$ref": "#/components/schemas/TypedManualUsageResponseMeteringUnitEnum" - } - ], + "documents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WarrantyDocument" + }, + "readOnly": true + } + }, + "required": [ + "asset_id", + "asset_tag", + "documents", + "host_id", + "host_kind", + "host_label", + "id", + "machine_id", + "machine_name", + "purchased_on", + "serial_number", + "status", + "vendor_contact", + "vendor_name", + "warranty_expires_on" + ] + }, + "WarrantyDocument": { + "type": "object", + "properties": { + "id": { + "type": "integer", "readOnly": true }, - "consumed_quantity": { + "original_filename": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", "readOnly": true }, - "hours": { + "content_type": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$", "readOnly": true }, - "note": { - "type": "string", + "size_bytes": { + "type": "integer", "readOnly": true }, "created_at": { @@ -105097,560 +128498,630 @@ } }, "required": [ - "consumable_pool_id", - "consumed_grams", - "consumed_quantity", + "content_type", "created_at", - "duration_minutes", - "hours", "id", - "machine_id", - "metering_unit", - "note", - "outcome", - "percent_complete", - "reason", - "service_request_id" + "original_filename", + "size_bytes" ] }, - "TypedManualUsageResponseMeteringUnitEnum": { - "enum": [ - "minutes", - "weight", - "volume", - "length", - "count" - ], - "type": "string", - "description": "* `minutes` - Minutes\n* `weight` - Weight\n* `volume` - Volume\n* `length` - Length\n* `count` - Count" - }, - "UnsubscribeResult": { + "WarrantyDocumentFinalize": { "type": "object", "properties": { - "detail": { - "type": "string" + "object_key": { + "type": "string", + "maxLength": 300 + }, + "original_filename": { + "type": "string", + "maxLength": 255 } }, "required": [ - "detail" + "object_key", + "original_filename" ] }, - "UsageResource": { + "WarrantyDocumentPresign": { "type": "object", "properties": { - "key": { - "type": "string" - }, - "label": { - "type": "string" - }, - "used": { - "type": "integer", - "nullable": true - }, - "limit": { - "type": "integer", - "nullable": true + "filename": { + "type": "string", + "maxLength": 255 }, - "unit": { - "$ref": "#/components/schemas/UsageResourceUnitEnum" + "content_type": { + "type": "string", + "maxLength": 100 } }, "required": [ - "key", - "label", - "limit", - "unit", - "used" + "content_type", + "filename" ] }, - "UsageResourceUnitEnum": { - "enum": [ - "bytes", - "count" - ], - "type": "string", - "description": "* `bytes` - bytes\n* `count` - count" - }, - "User": { + "WarrantyDocumentUploadResponse": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "username": { - "type": "string", - "description": "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", - "pattern": "^[\\w.@+-]+$", - "maxLength": 150 - }, - "email": { - "title": "Email address", - "oneOf": [ - { - "type": "string", - "format": "email", - "maxLength": 254 - }, - { - "type": "string", - "maxLength": 0 - } - ] - }, - "first_name": { - "type": "string", - "maxLength": 150 - }, - "last_name": { - "type": "string", - "maxLength": 150 - }, - "role": { - "$ref": "#/components/schemas/UserRoleEnum" - }, - "access_status": { - "$ref": "#/components/schemas/AccessStatusEnum" - }, - "restriction_reason": { + "object_key": { "type": "string" }, - "telegram_user_id": { - "type": "string", - "maxLength": 64 - }, - "external_checkin_user_id": { + "upload": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "object_key", + "upload" + ] + }, + "WarrantyDocumentUrl": { + "type": "object", + "properties": { + "url": { "type": "string", - "maxLength": 128 - }, - "is_active": { - "type": "boolean", - "title": "Active", - "description": "Designates whether this user should be treated as active. Unselect this instead of deleting accounts." + "format": "uri" } }, "required": [ - "id", - "username" + "url" ] }, - "UserRoleEnum": { - "enum": [ - "superadmin", - "space_manager", - "requester" - ], - "type": "string", - "description": "* `superadmin` - Super Admin\n* `space_manager` - Space Manager\n* `requester` - Requester" - }, - "VerificationAccepted": { + "WarrantyExpiryReport": { "type": "object", "properties": { - "id": { - "type": "integer" - }, - "state": { - "type": "string" - }, - "deduplicated": { - "type": "boolean" + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "status_url": { - "type": "string" + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WarrantyExpiryRow" + } } }, "required": [ - "deduplicated", - "id", - "state", - "status_url" + "rows", + "typed_rows" ] }, - "VerificationCoverage": { + "WarrantyExpiryRow": { "type": "object", "properties": { - "attempt": { - "type": "integer" - }, - "chain_status": { - "$ref": "#/components/schemas/ChainStatusEnum" - }, - "anchor_status": { - "$ref": "#/components/schemas/AnchorStatusEnum" - }, - "row_count": { - "type": "integer" - }, - "mac_valid_count": { - "type": "integer" - }, - "unattested_count": { + "makerspace_id": { "type": "integer" }, - "mac_invalid_count": { + "warranty_id": { "type": "integer" }, - "mac_unavailable_count": { + "machine_id": { "type": "integer" }, - "unbatched_count": { - "type": "integer" + "machine_name": { + "type": "string" }, - "local_batch_verified_count": { - "type": "integer" + "machine_type": { + "type": "string" }, - "batch_invalid_count": { - "type": "integer" + "purchased_on": { + "type": "string", + "format": "date", + "nullable": true }, - "batch_unavailable_count": { - "type": "integer" + "warranty_expires_on": { + "type": "string", + "format": "date" }, - "externally_anchored_count": { + "days_until_expiry": { "type": "integer" } }, "required": [ - "anchor_status", - "attempt", - "batch_invalid_count", - "batch_unavailable_count", - "chain_status", - "externally_anchored_count", - "local_batch_verified_count", - "mac_invalid_count", - "mac_unavailable_count", - "mac_valid_count", - "row_count", - "unattested_count", - "unbatched_count" + "days_until_expiry", + "machine_id", + "machine_name", + "machine_type", + "purchased_on", + "warranty_expires_on", + "warranty_id" ] }, - "VerificationFilters": { + "WarrantyReportRow": { "type": "object", "properties": { - "action": { + "host_kind": { "type": "string" }, - "target_type": { - "type": "string" + "host_id": { + "type": "integer" }, - "target_id": { + "host_label": { "type": "string" }, - "actor": { - "type": "integer", + "serial_number": { + "type": "string", "nullable": true }, - "start": { + "vendor_name": { "type": "string", - "format": "date-time", "nullable": true }, - "end": { + "purchased_on": { "type": "string", - "format": "date-time", + "format": "date", "nullable": true - } - }, - "required": [ - "action", - "actor", - "end", - "start", - "target_id", - "target_type" - ] - }, - "VerificationReport": { - "type": "object", - "properties": { - "format_version": { - "type": "integer" - }, - "target_makerspace_id": { - "type": "integer" - }, - "imported": { - "type": "object", - "additionalProperties": {} - }, - "resolved": { - "type": "object", - "additionalProperties": {} - }, - "dropped": { - "type": "object", - "additionalProperties": {} }, - "identities_linked": { - "type": "integer" + "warranty_expires_on": { + "type": "string", + "format": "date", + "nullable": true }, - "identities_created": { - "type": "integer" + "status": { + "type": "string" }, - "external_references_created": { + "document_count": { "type": "integer" } }, "required": [ - "dropped", - "external_references_created", - "format_version", - "identities_created", - "identities_linked", - "imported", - "resolved", - "target_makerspace_id" + "document_count", + "host_id", + "host_kind", + "host_label", + "purchased_on", + "serial_number", + "status", + "vendor_name", + "warranty_expires_on" ] }, - "VerificationRequest": { + "WarrantyUpsert": { "type": "object", - "description": "The audit-list filters, minus ``makerspace`` (it comes from the path).", "properties": { - "action": { - "type": "string", - "maxLength": 128 - }, - "target_type": { + "purchased_on": { "type": "string", - "maxLength": 200 + "format": "date", + "nullable": true }, - "target_id": { + "warranty_expires_on": { "type": "string", - "maxLength": 100 - }, - "actor": { - "type": "integer", - "minimum": 1 + "format": "date", + "nullable": true }, - "start": { + "vendor_name": { "type": "string", - "format": "date-time" + "maxLength": 200 }, - "end": { + "vendor_contact": { "type": "string", - "format": "date-time" + "maxLength": 200 } } }, - "VerificationRun": { + "WatchResponse": { "type": "object", "properties": { - "id": { - "type": "integer" + "subscribed": { + "type": "boolean" + } + }, + "required": [ + "subscribed" + ] + }, + "WebhookResponse": { + "type": "object", + "description": "Every provider webhook answers `{\"detail\": ...}`, acknowledged or refused.", + "properties": { + "detail": { + "type": "string" + } + }, + "required": [ + "detail" + ] + }, + "Website": { + "type": "object", + "properties": { + "tagline": { + "type": "string", + "maxLength": 300 }, - "makerspace": { - "type": "integer" + "accent_color": { + "oneOf": [ + { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" + }, + { + "type": "string", + "maxLength": 0 + } + ] }, - "state": { - "$ref": "#/components/schemas/VerificationRunStateEnum" + "section_order": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SectionOrderEnum" + }, + "maxItems": 9, + "minItems": 9 }, - "terminal": { - "type": "boolean" + "section_enabled": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } }, - "snapshot_max_id": { + "venue": { + "allOf": [ + { + "$ref": "#/components/schemas/WebsiteVenue" + } + ], + "nullable": true + }, + "revision": { "type": "integer" }, - "filters": { - "$ref": "#/components/schemas/VerificationFilters" + "released": { + "type": "boolean" }, - "coverage": { - "$ref": "#/components/schemas/VerificationCoverage" + "inherited": { + "type": "boolean" }, - "created_at": { - "type": "string", - "format": "date-time" + "sections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebsiteSection" + } }, - "updated_at": { - "type": "string", - "format": "date-time" + "sessions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebsiteSession" + } }, - "status_url": { + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebsiteAsset" + } + } + }, + "required": [ + "inherited", + "released", + "revision", + "sections", + "sessions" + ] + }, + "WebsiteAsset": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "alt_text": { "type": "string" }, - "failure_code": { + "size_bytes": { + "type": "integer" + } + }, + "required": [ + "alt_text", + "id", + "size_bytes" + ] + }, + "WebsiteAssetInput": { + "type": "object", + "properties": { + "file": { "type": "string", - "description": "Superuser only: closed operational failure code." + "format": "uri" }, - "first_audit_log_id": { - "type": "integer", - "nullable": true, - "description": "Superuser only diagnostic." + "alt_text": { + "type": "string", + "maxLength": 200 }, - "first_batch_seq": { + "replace_id": { "type": "integer", - "nullable": true, - "description": "Superuser only diagnostic." + "minimum": 1 } }, "required": [ - "coverage", - "created_at", - "filters", - "id", - "makerspace", - "snapshot_max_id", - "state", - "status_url", - "terminal", - "updated_at" + "alt_text", + "file" ] }, - "VerificationRunPage": { + "WebsiteContentInput": { "type": "object", "properties": { - "count": { - "type": "integer" - }, - "next": { + "tagline": { "type": "string", - "format": "uri", - "nullable": true + "maxLength": 300 }, - "previous": { - "type": "string", - "format": "uri", + "accent_color": { + "oneOf": [ + { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "section_order": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SectionOrderEnum" + }, + "maxItems": 9, + "minItems": 9 + }, + "section_enabled": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + }, + "venue": { + "allOf": [ + { + "$ref": "#/components/schemas/WebsiteVenue" + } + ], "nullable": true }, - "results": { + "revision": { + "type": "integer", + "minimum": 0 + }, + "sections": { "type": "array", "items": { - "$ref": "#/components/schemas/VerificationRun" + "$ref": "#/components/schemas/WebsiteSectionInput" + } + }, + "sessions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebsiteSessionInput" } } }, "required": [ - "count", - "next", - "previous", - "results" + "revision", + "sections", + "sessions" ] }, - "VerificationRunStateEnum": { - "enum": [ - "QUEUED", - "RUNNING", - "RETRYING", - "SUCCEEDED", - "INCOMPLETE", - "FAILED", - "TIMED_OUT" - ], - "type": "string", - "description": "* `QUEUED` - Queued\n* `RUNNING` - Running\n* `RETRYING` - Retrying\n* `SUCCEEDED` - Succeeded\n* `INCOMPLETE` - Incomplete\n* `FAILED` - Failed\n* `TIMED_OUT` - Timed out" + "WebsiteDelayInput": { + "type": "object", + "properties": { + "delay_minutes": { + "type": "integer", + "maximum": 1440, + "minimum": 0 + } + }, + "required": [ + "delay_minutes" + ] }, - "VersionEnum": { - "enum": [ - 1 - ], - "type": "integer", - "description": "* `1` - 1" + "WebsiteOrigins": { + "type": "object", + "properties": { + "origins": { + "type": "array", + "items": { + "type": "string", + "maxLength": 255 + }, + "maxItems": 20 + } + }, + "required": [ + "origins" + ] }, - "Vouch": { + "WebsiteReleaseInput": { "type": "object", "properties": { - "voucher_id": { - "type": "integer" - }, - "voucher_name": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "released": { + "type": "boolean" }, - "expires_at": { - "type": "string", - "format": "date-time" + "revision": { + "type": "integer", + "minimum": 1 } }, "required": [ - "created_at", - "expires_at", - "voucher_id", - "voucher_name" + "released", + "revision" ] }, - "WaiverAcceptResponse": { + "WebsiteSection": { "type": "object", "properties": { - "accepted": { - "type": "boolean" + "kind": { + "$ref": "#/components/schemas/Kind21fEnum" + }, + "slug": { + "type": "string", + "maxLength": 80, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "title": { + "type": "string", + "maxLength": 200 + }, + "body": { + "type": "string", + "default": "", + "maxLength": 10000 + }, + "url": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "default": "", + "maxLength": 500 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "headline": { + "type": "string", + "maxLength": 200 + }, + "organization": { + "type": "string", + "maxLength": 200 + }, + "website_url": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "maxLength": 500 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "linkedin_url": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "maxLength": 500 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "x_url": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "maxLength": 500 + }, + { + "type": "string", + "maxLength": 0 + } + ] }, - "version": { + "sponsor_tier": { + "$ref": "#/components/schemas/SponsorTierEnum" + }, + "icon": { + "type": "string", + "maxLength": 40 + }, + "asset_id": { + "type": "integer", + "minimum": 1, + "nullable": true + }, + "visibility": { + "allOf": [ + { + "$ref": "#/components/schemas/Visibility68aEnum" + } + ], + "default": "draft" + }, + "position": { + "type": "integer", + "maximum": 1000, + "minimum": 0, + "default": 0 + }, + "image_url": { "type": "string", "nullable": true + }, + "image_alt_text": { + "type": "string" } }, "required": [ - "accepted", - "version" + "image_alt_text", + "image_url", + "kind", + "slug", + "title" ] }, - "WaiverPublish": { + "WebsiteSectionInput": { "type": "object", "properties": { - "body": { - "type": "string" + "kind": { + "$ref": "#/components/schemas/Kind21fEnum" }, - "version": { + "slug": { "type": "string", - "maxLength": 64 + "maxLength": 80, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "clear": { - "type": "boolean", - "default": false - } - } - }, - "WalkInAdmissionOutcome": { - "type": "object", - "properties": { - "user_id": { - "type": "integer" + "title": { + "type": "string", + "maxLength": 200 }, - "display_name": { - "type": "string" + "body": { + "type": "string", + "default": "", + "maxLength": 10000 }, - "username": { - "type": "string" + "url": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "default": "", + "maxLength": 500 + }, + { + "type": "string", + "maxLength": 0 + } + ] }, - "admission_request": { - "$ref": "#/components/schemas/AdmissionRequest" - } - }, - "required": [ - "admission_request", - "display_name", - "user_id", - "username" - ] - }, - "WalkInAdmissionResult": { - "oneOf": [ - { - "$ref": "#/components/schemas/DirectLoanMember" + "headline": { + "type": "string", + "maxLength": 200 }, - { - "$ref": "#/components/schemas/WalkInAdmissionOutcome" - } - ] - }, - "WalkInMemberCreate": { - "type": "object", - "properties": { - "display_name": { + "organization": { "type": "string", "maxLength": 200 }, - "email": { + "website_url": { "oneOf": [ { "type": "string", - "format": "email", - "default": "" + "format": "uri", + "maxLength": 500 }, { "type": "string", @@ -105658,301 +129129,357 @@ } ] }, - "phone": { + "linkedin_url": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "maxLength": 500 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "x_url": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "maxLength": 500 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "sponsor_tier": { + "$ref": "#/components/schemas/SponsorTierEnum" + }, + "icon": { "type": "string", - "default": "", - "maxLength": 32 + "maxLength": 40 }, - "application_answers": { + "asset_id": { + "type": "integer", + "minimum": 1, "nullable": true + }, + "visibility": { + "allOf": [ + { + "$ref": "#/components/schemas/Visibility68aEnum" + } + ], + "default": "draft" + }, + "position": { + "type": "integer", + "maximum": 1000, + "minimum": 0, + "default": 0 } }, "required": [ - "display_name" + "kind", + "slug", + "title" ] }, - "Warranty": { + "WebsiteSession": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "host_kind": { + "slug": { "type": "string", - "readOnly": true - }, - "host_id": { - "type": "integer", - "readOnly": true + "maxLength": 80, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "host_label": { + "title": { "type": "string", - "readOnly": true + "maxLength": 200 }, - "asset_id": { - "type": "integer", - "nullable": true, - "readOnly": true + "body": { + "type": "string", + "default": "", + "maxLength": 10000 }, - "asset_tag": { + "starts_at": { "type": "string", - "nullable": true, - "readOnly": true + "format": "date-time" }, - "serial_number": { + "ends_at": { "type": "string", - "nullable": true, - "readOnly": true + "format": "date-time" }, - "machine_id": { - "type": "integer", + "track": { + "type": "string", "nullable": true, - "readOnly": true + "maxLength": 80, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "machine_name": { + "room": { "type": "string", "nullable": true, - "readOnly": true + "maxLength": 80, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "purchased_on": { - "type": "string", - "format": "date", - "readOnly": true, - "nullable": true + "speakers": { + "type": "array", + "items": { + "type": "string", + "maxLength": 80, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "maxItems": 20 }, - "warranty_expires_on": { - "type": "string", - "format": "date", - "readOnly": true, - "nullable": true + "visibility": { + "allOf": [ + { + "$ref": "#/components/schemas/Visibility68aEnum" + } + ], + "default": "draft" }, - "vendor_name": { - "type": "string", - "readOnly": true + "delay_minutes": { + "type": "integer" }, - "vendor_contact": { + "effective_starts_at": { "type": "string", - "readOnly": true + "format": "date-time" }, - "status": { + "effective_ends_at": { "type": "string", - "readOnly": true - }, - "documents": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WarrantyDocument" - }, - "readOnly": true + "format": "date-time" } }, "required": [ - "asset_id", - "asset_tag", - "documents", - "host_id", - "host_kind", - "host_label", - "id", - "machine_id", - "machine_name", - "purchased_on", - "serial_number", - "status", - "vendor_contact", - "vendor_name", - "warranty_expires_on" + "delay_minutes", + "effective_ends_at", + "effective_starts_at", + "ends_at", + "slug", + "starts_at", + "title" ] }, - "WarrantyDocument": { + "WebsiteSessionInput": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "original_filename": { + "slug": { "type": "string", - "readOnly": true + "maxLength": 80, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "content_type": { + "title": { "type": "string", - "readOnly": true - }, - "size_bytes": { - "type": "integer", - "readOnly": true + "maxLength": 200 }, - "created_at": { + "body": { "type": "string", - "format": "date-time", - "readOnly": true - } - }, - "required": [ - "content_type", - "created_at", - "id", - "original_filename", - "size_bytes" - ] - }, - "WarrantyDocumentFinalize": { - "type": "object", - "properties": { - "object_key": { + "default": "", + "maxLength": 10000 + }, + "starts_at": { "type": "string", - "maxLength": 300 + "format": "date-time" }, - "original_filename": { + "ends_at": { "type": "string", - "maxLength": 255 - } - }, - "required": [ - "object_key", - "original_filename" - ] - }, - "WarrantyDocumentPresign": { - "type": "object", - "properties": { - "filename": { + "format": "date-time" + }, + "track": { "type": "string", - "maxLength": 255 + "nullable": true, + "maxLength": 80, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "content_type": { + "room": { "type": "string", - "maxLength": 100 - } - }, - "required": [ - "content_type", - "filename" - ] - }, - "WarrantyDocumentUploadResponse": { - "type": "object", - "properties": { - "object_key": { - "type": "string" + "nullable": true, + "maxLength": 80, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "upload": { - "type": "object", - "additionalProperties": {} + "speakers": { + "type": "array", + "items": { + "type": "string", + "maxLength": 80, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "maxItems": 20 + }, + "visibility": { + "allOf": [ + { + "$ref": "#/components/schemas/Visibility68aEnum" + } + ], + "default": "draft" } }, "required": [ - "object_key", - "upload" + "ends_at", + "slug", + "starts_at", + "title" ] }, - "WarrantyDocumentUrl": { + "WebsiteVenue": { "type": "object", "properties": { - "url": { + "name": { "type": "string", - "format": "uri" - } - }, - "required": [ - "url" - ] - }, - "WarrantyReportRow": { - "type": "object", - "properties": { - "host_kind": { - "type": "string" + "default": "", + "maxLength": 200 }, - "host_id": { - "type": "integer" + "address": { + "type": "string", + "default": "", + "maxLength": 500 }, - "host_label": { - "type": "string" + "city": { + "type": "string", + "default": "", + "maxLength": 200 }, - "serial_number": { + "country": { "type": "string", + "default": "", + "maxLength": 200 + }, + "latitude": { + "type": "number", + "format": "double", + "maximum": 90, + "minimum": -90, "nullable": true }, - "vendor_name": { - "type": "string", + "longitude": { + "type": "number", + "format": "double", + "maximum": 180, + "minimum": -180, "nullable": true }, - "purchased_on": { + "map_url": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "default": "", + "maxLength": 500 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "directions": { "type": "string", - "format": "date", - "nullable": true + "default": "", + "maxLength": 2000 }, - "warranty_expires_on": { + "parking": { "type": "string", - "format": "date", - "nullable": true + "default": "", + "maxLength": 2000 }, - "status": { - "type": "string" + "public_transport": { + "type": "string", + "default": "", + "maxLength": 2000 }, - "document_count": { - "type": "integer" + "accessibility_notes": { + "type": "string", + "default": "", + "maxLength": 2000 } - }, - "required": [ - "document_count", - "host_id", - "host_kind", - "host_label", - "purchased_on", - "serial_number", - "status", - "vendor_name", - "warranty_expires_on" - ] + } }, - "WarrantyUpsert": { + "WhatsAppBinding": { "type": "object", "properties": { - "purchased_on": { + "id": { + "type": "integer", + "readOnly": true + }, + "feature": { "type": "string", - "format": "date", - "nullable": true + "maxLength": 32 }, - "warranty_expires_on": { + "event": { "type": "string", - "format": "date", - "nullable": true + "maxLength": 64 }, - "vendor_name": { + "audience": { + "$ref": "#/components/schemas/Audience356Enum" + }, + "business_account_id": { "type": "string", - "maxLength": 200 + "readOnly": true }, - "vendor_contact": { + "template_name": { "type": "string", - "maxLength": 200 - } - } - }, - "WatchResponse": { - "type": "object", - "properties": { - "subscribed": { + "maxLength": 512 + }, + "language": { + "type": "string", + "maxLength": 16 + }, + "parameters": {}, + "is_active": { "type": "boolean" + }, + "needs_review": { + "type": "boolean", + "readOnly": true + }, + "components": { + "writeOnly": true } }, "required": [ - "subscribed" + "audience", + "business_account_id", + "event", + "feature", + "id", + "language", + "needs_review", + "template_name" ] }, - "WebhookResponse": { + "WhatsAppBindingPage": { "type": "object", - "description": "Every provider webhook answers `{\"detail\": ...}`, acknowledged or refused.", "properties": { - "detail": { - "type": "string" + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "format": "uri", + "nullable": true + }, + "previous": { + "type": "string", + "format": "uri", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WhatsAppBinding" + } } }, "required": [ - "detail" + "count", + "next", + "previous", + "results" ] }, "WitnessWaiverResponse": { @@ -106037,10 +129564,17 @@ "name": "sw_kiosk_station", "description": "Signed, Station/version/session-bound kiosk cookie. Session-bound bearer material, not device-bound." }, + "ServerHMAC": { + "type": "apiKey", + "in": "header", + "name": "X-Signature", + "description": "Tenant-bound server HMAC over METHOD, full path, timestamp, nonce (when present), body. X-Client-Id and X-Timestamp also required; see API client protocol." + }, "jwtAuth": { "type": "http", "scheme": "bearer", - "bearerFormat": "JWT" + "bearerFormat": "JWT", + "description": "Native staff sessions require a live device grant with staff authority. On non-member APIs, X-Makerspace-Id is mandatory and must select an active, servable local membership matching all URL/body targets. Member APIs may omit the header. Personal notification inbox, preferences and announcements accept member sessions; broadcasts and the makerspace-wide notification feed require staff authority." } } }, diff --git a/api/provenance.json b/api/provenance.json index 86e465e..90f663d 100644 --- a/api/provenance.json +++ b/api/provenance.json @@ -1,11 +1,14 @@ { - "source": "Owner-supplied SpaceWorks/frontend/openapi-schema.json", + "source": "Owner backend mobile contract snapshot SpaceWorks/frontend/openapi-schema.json", "source_repository": "https://github.com/SpaceWorks-HQ/SpaceWorks.git", - "source_file_commit": "e7c2803a4a57870e11dc4faf9adc40ee54401632", - "received": "2026-10-16", + "source_branch": "feature/mobile-backend", + "source_file_commit": "3123173da58dee1d174ac5be8b9f3a9cc3313ffb", + "source_head": "f65ba332f702ac49dbd59d3897490bccf3e18053", + "received": "2026-10-18", "openapi": "3.0.3", - "paths": 635, - "bytes": 4063303, - "sha256": "d5ec904eb2a52ca948ee38dae7840c8859a43e034a22f3b8dc478fd8851cbb9b", - "note": "Copied byte-for-byte; no backend source accessed. Operations already include the API prefix, so clients use a validated deployment origin. The generator filter selects implemented operations and their generated dependencies. The mechanical build projection selects documented JSON request media or an optional no-body request where malformed content declares no media type; paths, DTO fields and response schemas are preserved." + "paths": 760, + "bytes": 4997776, + "sha256": "962e6a4b5b6927db0214c11e4b3b5ff688f660dccb8e674a7403b996cb1d8460", + "supersedes_sha256": "d5ec904eb2a52ca948ee38dae7840c8859a43e034a22f3b8dc478fd8851cbb9b", + "note": "Copied byte-for-byte from the read-only mobile backend worktree; the backend was not modified. Operations already include the API prefix, so clients use a validated deployment origin. The generator filter selects implemented operations and their generated dependencies. The mechanical build projection selects documented JSON request media or an optional no-body request where malformed content declares no media type; paths, DTO fields and response schemas are preserved." } diff --git a/docs/mobile-auth-ux-spec.md b/docs/mobile-auth-ux-spec.md index 3099a22..c42ded2 100644 --- a/docs/mobile-auth-ux-spec.md +++ b/docs/mobile-auth-ux-spec.md @@ -8,7 +8,7 @@ The owner requested a complete, polished onboarding/authentication experience in The original mobile PRD remains the scope owner: `SpaceWorks/docs/mobile/MOBILE-APP-PRD.md`. The owner has deferred backend work. Build complete, explicitly labelled debug/demo journeys for unavailable contracts; production must continue to use generated operations and fail safely where native authorization is unavailable. Do not create new routes, payload fields, access grants, successful payments, or deletion results to make screens appear complete. -Contract reference: the apps' `api/openapi.json`, SHA-256 `d5ec904eb2a52ca948ee38dae7840c8859a43e034a22f3b8dc478fd8851cbb9b`. Paths below include `/api/v1`, as do the generated operations. Configure the server origin without duplicating that prefix. A different schema in a local backend worktree is not a replacement contract. +Contract reference: the apps' `api/openapi.json`, SHA-256 `962e6a4b5b6927db0214c11e4b3b5ff688f660dccb8e674a7403b996cb1d8460`. Paths below include `/api/v1`, as do the generated operations. Configure the server origin without duplicating that prefix. A different schema in a local backend worktree is not a replacement contract. Brand inputs: `design/tokens/tokens.json` is authoritative for color; `tokens/mobile-motion.json` is the shared machine-readable mobile motion contract generated into each app; `design/docs/ui-brand-spec.md` supplies the logo, spacing, motion provenance and voice; `design/docs/ux-yuyu-spec.md` and `design/docs/ux-screens-longtail.md` supply task-based layouts and states; `design/brand/space-works-brand-guidelines-v1.pdf` and its supplied page exports support visual verification. The owner's later full-brand instruction uses the supplied Gilroy faces for app content with native Dynamic Type/Bold Text support; platform bars and system-owned controls retain native typography. IDs and secrets use the app family with tabular digits. Font provenance and applicable licenses remain separate from implementation: supplied assets and this document grant no additional embedding rights. diff --git a/scripts/tests/test_generation.py b/scripts/tests/test_generation.py index 86af1d8..4525af3 100644 --- a/scripts/tests/test_generation.py +++ b/scripts/tests/test_generation.py @@ -31,7 +31,7 @@ def test_authoritative_contract_is_unchanged_and_staged_for_plugin(self): elif content and not any("/" in media for media in content) and not operation["requestBody"].get("required", False): del operation["requestBody"] self.assertEqual(staged, expected, "Only documented JSON or optional no-body representations may be selected; all models remain unchanged") - self.assertEqual(len(json.loads(source)["paths"]), 635) + self.assertEqual(len(json.loads(source)["paths"]), 760) def test_brand_config_drives_project_display_name_bundle_and_icon(self): brand = json.loads((ROOT / "branding/brand.json").read_text()) diff --git a/scripts/tests/test_member_fixtures.py b/scripts/tests/test_member_fixtures.py new file mode 100644 index 0000000..4428cee --- /dev/null +++ b/scripts/tests/test_member_fixtures.py @@ -0,0 +1,66 @@ +"""Check synthetic member data against the generated operation response schemas it is decoded as.""" +import json +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[2] +OPERATIONS = { + "activity": "api_v1_member_makerspaces_activity_retrieve", + "bookings": "api_v1_public_my_bookings_retrieve", + "bootstrap": "api_v1_bootstrap_retrieve", + "card": "api_v1_member_makerspaces_member_card_retrieve", + "categories": "api_v1_public_inventory_categories_list", + "events": "api_v1_public_events_list", + "inbox": "api_v1_notifications_inbox_retrieve", + "inventory": "api_v1_public_inventory_list", + "spaces": "api_v1_public_spaces_list", +} + + +class MemberFixturesTests(unittest.TestCase): + def test_member_fixtures_satisfy_their_operation_response_schemas(self): + document = json.loads((ROOT / "api/openapi.json").read_text(encoding="utf-8")) + schemas = document["components"]["schemas"] + operations = {operation["operationId"]: operation for path in document["paths"].values() + for operation in path.values() if isinstance(operation, dict) and "operationId" in operation} + + def check(value, schema, path): + if value is None and schema.get("nullable"): + return + if "$ref" in schema: + return check(value, schemas[schema["$ref"].split("/")[-1]], path) + if "allOf" in schema: + for component in schema["allOf"]: + check(value, component, path) + return + if "enum" in schema: + self.assertIn(value, schema["enum"], path) + kind = schema.get("type") + if kind == "object": + self.assertIsInstance(value, dict, path) + for key in schema.get("required", []): + self.assertIn(key, value, path) + for key, child in value.items(): + if key in schema.get("properties", {}): + check(child, schema["properties"][key], path + "." + key) + elif kind == "array": + self.assertIsInstance(value, list, path) + for index, child in enumerate(value): + check(child, schema["items"], path + "." + str(index)) + elif kind == "integer": + self.assertIsInstance(value, int, path) + self.assertNotIsInstance(value, bool, path) + elif kind == "string": + self.assertIsInstance(value, str, path) + elif kind == "boolean": + self.assertIsInstance(value, bool, path) + + for name, operation in OPERATIONS.items(): + with self.subTest(fixture=name): + schema = operations[operation]["responses"]["200"]["content"]["application/json"]["schema"] + fixture = json.loads((ROOT / "Fixtures/Member" / (name + ".json")).read_text(encoding="utf-8")) + check(fixture, schema, name) + + +if __name__ == "__main__": + unittest.main() From 8fa90115bff8a4d668467c44fac9d64a22f48d07 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:34:52 +0530 Subject: [PATCH 84/98] feat(d2): native provider, phone and version-gated device sign-in Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- .../SpaceWorksAPI/AuthOperations.swift | 74 ++++++ SpaceWorks/AppVersion.swift | 29 +++ SpaceWorks/AuthState.swift | 173 +++++++++++-- SpaceWorks/AuthView.swift | 14 +- SpaceWorks/DebugAuthFixtures.swift | 5 + SpaceWorks/NativeAttestor.swift | 4 +- SpaceWorks/NativeSignInViews.swift | 143 +++++++++++ SpaceWorks/PhoneSignInState.swift | 78 ++++++ SpaceWorks/Platform/NativeIdentity.swift | 68 ++++-- SpaceWorks/Platform/NativeOIDC.swift | 114 +++++++++ SpaceWorks/RootView.swift | 12 + SpaceWorks/SpaceWorksApp.swift | 11 +- SpaceWorksTests/MemberActionsTests.swift | 2 +- SpaceWorksTests/NativeAuthTests.swift | 231 ++++++++++++++++++ SpaceWorksTests/NativeIdentityTests.swift | 61 ++++- SpaceWorksUITests/NativeAuthFlowTests.swift | 89 +++++++ 16 files changed, 1061 insertions(+), 47 deletions(-) create mode 100644 Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/AuthOperations.swift create mode 100644 SpaceWorks/AppVersion.swift create mode 100644 SpaceWorks/NativeSignInViews.swift create mode 100644 SpaceWorks/PhoneSignInState.swift create mode 100644 SpaceWorks/Platform/NativeOIDC.swift create mode 100644 SpaceWorksTests/NativeAuthTests.swift create mode 100644 SpaceWorksUITests/NativeAuthFlowTests.swift diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/AuthOperations.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/AuthOperations.swift new file mode 100644 index 0000000..f1ef342 --- /dev/null +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/AuthOperations.swift @@ -0,0 +1,74 @@ +import Foundation +import OpenAPIRuntime + +/// A server-published native provider. OIDC operations address the configured provider slug. +public enum NativeSocialProvider: Sendable, Equatable { + case google, apple, oidc(slug: String) + /// The nonce operation's documented provider key (`oidc:{slug}` for configured OIDC). + public var nonceKey: String { + switch self { + case .google: return "google" + case .apple: return "apple" + case .oidc(let slug): return "oidc:" + slug + } + } +} + +extension SpaceWorksService { + /// Member device delivery; the nonce is bound to this attestation challenge. + public func socialNonce(provider: NativeSocialProvider, challenge: String) async throws -> Components.Schemas.SocialNonceResponse { + let input = Components.Schemas.SocialNonce(provider: provider.nonceKey, surface: .member, delivery: .device, + client_platform: .ios, challenge: challenge) + return try await publicClient.api_v1_auth_social_nonce_create(.init(body: .json(input))).ok.body.json + } + + public func socialSignIn(provider: NativeSocialProvider, idToken: String, nonce: String, challenge: String, + attestationJSON: Data, authorizationCode: String? = nil, + appleName: String? = nil) async throws -> Components.Schemas.SocialLoginResponse { + let input = Components.Schemas.SocialLogin(authorization_code: authorizationCode, id_token: idToken, nonce: nonce, + surface: .member, delivery: .device, client_platform: .ios, apple_name: appleName, challenge: challenge, + attestation: .init(value1: try DeviceAttestation.payload(attestationJSON))) + switch provider { + case .google: + return try await publicClient.api_v1_auth_social_google_create(.init(body: .json(input))).ok.body.json + case .apple: + return try await publicClient.api_v1_auth_social_apple_create(.init(body: .json(input))).ok.body.json + case .oidc(let slug): + return try await publicClient.api_v1_auth_social_oidc_create(.init(path: .init(slug: slug), body: .json(input))).ok.body.json + } + } + + /// The start response is uniform and never discloses whether an account exists. + public func phoneSignInStart(phone: String, challenge: String) async throws { + let input = Components.Schemas.PhoneLoginStart(phone: phone, delivery: .init(value1: .device), + client_platform: .init(value1: .ios), challenge: challenge) + _ = try await publicClient.api_v1_auth_phone_login_start_create(.init(body: .json(input))).ok.body.json + } + + public func phoneSignInConfirm(phone: String, code: String, challenge: String, + attestationJSON: Data) async throws -> Components.Schemas.PhoneLoginResult { + let input = Components.Schemas.PhoneLoginConfirm(phone: phone, delivery: .init(value1: .device), + client_platform: .init(value1: .ios), challenge: challenge, code: code, + attestation: .init(value1: try DeviceAttestation.payload(attestationJSON))) + return try await publicClient.api_v1_auth_phone_login_confirm_create(.init(body: .json(input))).ok.body.json + } + + /// Revokes browser sessions, device grants and their push destinations on this server. + public func signOutEverywhere() async throws -> Components.Schemas.LogoutAllResponse { + try await client.api_v1_auth_logout_all_create().ok.body.json + } +} + +/// Native device completions must carry a refresh token and a device grant with a UUID id. +public enum NativeDeviceCompletion { + public static func grantID(_ grant: T?) -> UUID? { + guard let grant, let data = try? JSONEncoder().encode(grant), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let id = object["id"] as? String else { return nil } + return UUID(uuidString: id) + } + public static func tokens(access: String, refresh: String?, grant: G?, surface: String? = nil) -> TokenPair? { + guard let refresh, !refresh.isEmpty, !access.isEmpty, surface != "verification_only", grantID(grant) != nil else { return nil } + return TokenPair(access: access, refresh: refresh) + } +} diff --git a/SpaceWorks/AppVersion.swift b/SpaceWorks/AppVersion.swift new file mode 100644 index 0000000..758fea2 --- /dev/null +++ b/SpaceWorks/AppVersion.swift @@ -0,0 +1,29 @@ +import Foundation + +/// The server's `native.min_app_version.ios` gate compares dotted numeric components, never strings. +enum AppVersion { + static var current: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "" } + + /// A missing or malformed minimum never blocks; a version that cannot be read is treated as outdated. + static func isBelow(_ version: String, minimum: String?) -> Bool { + guard let minimum, let required = components(minimum) else { return false } + guard let actual = components(version) else { return true } + for index in 0.. [Int]? { + let parts = value.trimmingCharacters(in: .whitespaces).split(separator: ".", omittingEmptySubsequences: false) + guard !parts.isEmpty, parts.count <= 6 else { return nil } + var numbers: [Int] = [] + for part in parts { + guard !part.isEmpty, part.count <= 9, part.allSatisfy({ $0.isASCII && $0.isNumber }), let number = Int(part) else { return nil } + numbers.append(number) + } + return numbers + } +} diff --git a/SpaceWorks/AuthState.swift b/SpaceWorks/AuthState.swift index 6bed4a7..3f736cf 100644 --- a/SpaceWorks/AuthState.swift +++ b/SpaceWorks/AuthState.swift @@ -4,7 +4,7 @@ import SpaceWorksAPI @MainActor @Observable final class AuthState { - enum Stage: Equatable { case restoring, signIn, mfa, recoveryCodes, passwordChange, securityCompletion, signedIn } + enum Stage: Equatable { case restoring, updateRequired, signIn, webConsole, mfa, recoveryCodes, passwordChange, securityCompletion, signedIn } private(set) var stage: Stage = .restoring private(set) var feedback: NativeFeedbackEvent? var username = "" @@ -25,6 +25,7 @@ final class AuthState { let service: SpaceWorksService private let attest: @Sendable (String) async throws -> Data private let settings: UserDefaults + private let appVersion: String private let storage: any TokenStorage private var restored = false private var identityGeneration = 0 @@ -44,13 +45,14 @@ final class AuthState { private static var notificationOwner: UUID? init(origin: ServerOrigin, session: URLSession = DeploymentSession.make(), settings: UserDefaults = .standard, - storage: (any TokenStorage)? = nil, + storage: (any TokenStorage)? = nil, appVersion: String = AppVersion.current, attest: @escaping @Sendable (String) async throws -> Data = { try await NativeAttestor.attest($0) }) { let storage = storage ?? TokenVault(origin: origin) self.storage = storage self.origin = origin self.attest = attest self.settings = settings + self.appVersion = appVersion self.serverScope = origin.url.absoluteString var pair: TokenPair? var storageError: String? @@ -64,10 +66,25 @@ final class AuthState { } var selectedMembership: Components.Schemas.AuthMembership? { memberships.first { $0.id == selectedID } } var displayName: String { user?.display_name.isEmpty == false ? user!.display_name : user?.username ?? "" } + /// `PublicConfig.native` publishes the enabled native methods. Older servers without it keep the + /// documented password switch and offer no native providers. var passwordEnabled: Bool { - guard configuration != nil else { return false } + guard let configuration else { return false } + if let native = configuration.native { return native.providers.password } return configFlag("password_login") } + var phoneEnabled: Bool { configuration?.native?.providers.phone == true } + var googleClientID: String? { + guard let id = configuration?.native?.providers.google?.value1.ios_client_id, !id.isEmpty else { return nil } + return id + } + var appleEnabled: Bool { configuration?.native?.providers.apple?.value1.enabled == true } + var organizationProviders: [Components.Schemas.NativeOidc] { + configuration?.native?.providers.oidc.filter { NativeOIDCClient.slug(fromKey: $0.key) != nil && !$0.client_id.isEmpty } ?? [] + } + var hasProviders: Bool { googleClientID != nil || appleEnabled || !organizationProviders.isEmpty || phoneEnabled } + var minimumVersion: String? { configuration?.native?.min_app_version.ios ?? nil } + var updateRequired: Bool { AppVersion.isBelow(appVersion, minimum: minimumVersion) } var signupEnabled: Bool { configuration != nil && configFlag("member_accounts") && configFlag("self_registration") } private func configFlag(_ key: String) -> Bool { guard let configuration, let data = try? JSONEncoder().encode(configuration), @@ -76,8 +93,9 @@ final class AuthState { // PublicConfig's extensible object; same documented switch consumed by MemberAuthPanel. return login["enabled"] as? Bool != false } - // Fail closed until the schema supplies native_staff authority (backend 1a). - var staffToolsAvailable: Bool { false } + /// Staff tools require a native staff session; phone and member sessions never receive them. + /// Each tool is additionally gated by the selected membership's server-supplied actions. + var staffToolsAvailable: Bool { stage == .signedIn && user?.session_authority == "native_staff" } private var selectionKey: String { "makerspace-selection.\(serverScope).\(user?.id ?? 0)" } func retryRestore() async { guard !busy else { return } @@ -139,6 +157,8 @@ final class AuthState { restored = true do { configuration = try await service.configuration() + // Stored credentials stay in Keychain so an updated app can resume without signing in again. + if updateRequired { stage = .updateRequired; return } guard await service.refresh.current() != nil else { stage = .signIn; return } try await loadIdentity() } catch { @@ -169,17 +189,105 @@ final class AuthState { try await accept(TokenPair(access: tokens.access, refresh: tokens.refresh)) await authenticationFeedback(generation: feedbackGeneration) case .MFAChallengeResponse(let challenge): - mfa = challenge - mfaExpires = Date().addingTimeInterval(TimeInterval(challenge.expires_in)) - if challenge.enrollment_required { enrollment = try await service.enroll(challenge: challenge.challenge) } - stage = .mfa - if identityGeneration == feedbackGeneration { feedback = .init(.selection) } + try await beginMFA(challenge, generation: feedbackGeneration) + } + } catch { refuse(error, generation: feedbackGeneration) } + } + + /// Google, Apple and organization OIDC share one attested device flow: challenge, provider nonce, + /// native provider credential, attestation, then a device grant from the generated operation. + func signIn(with provider: NativeSocialProvider, credential: @escaping @MainActor (String) async throws -> ProviderCredential) async { + guard !busy, stage == .signIn, (retryAfter ?? .distantPast) <= Date() else { return } + let feedbackGeneration = identityGeneration + busy = true; error = nil + defer { busy = false } + do { + let challenge = try await service.challenge(appID: Brand.bundleID, development: NativeAttestor.development) + let expires = Date().addingTimeInterval(TimeInterval(challenge.expires_in)) + let nonce = try await service.socialNonce(provider: provider, challenge: challenge.challenge) + let provided = try await credential(nonce.nonce) + let proof = try await attest(challenge.challenge) + guard Date() < expires else { throw expiredSignIn } + let result = try await service.socialSignIn(provider: provider, idToken: provided.idToken, nonce: nonce.nonce, + challenge: challenge.challenge, attestationJSON: proof, authorizationCode: provided.authorizationCode, appleName: provided.appleName) + guard let pair = NativeDeviceCompletion.tokens(access: result.access, refresh: result.refresh, grant: result.device_grant) else { throw missingDeviceSession } + try await accept(pair) + await authenticationFeedback(generation: feedbackGeneration) + } catch { refuse(error, generation: feedbackGeneration) } + } + + /// Phone sign-in is member-only. The code request binds a fresh attestation challenge. + func phoneSignIn() -> PhoneSignInState { + let service = service + return PhoneSignInState(begin: { phone in + let challenge = try await service.challenge(appID: Brand.bundleID, development: NativeAttestor.development) + let started = PhoneSignInState.Started(challenge: challenge.challenge, expires: Date().addingTimeInterval(TimeInterval(challenge.expires_in))) + try await service.phoneSignInStart(phone: phone, challenge: challenge.challenge) + return started + }, finish: { [weak self] phone, code, challenge in + guard let self else { throw CancellationError() } + try await self.completePhoneSignIn(phone: phone, code: code, challenge: challenge) + }) + } + + private func completePhoneSignIn(phone: String, code: String, challenge: String) async throws { + guard !busy, stage == .signIn else { throw CancellationError() } + let feedbackGeneration = identityGeneration + busy = true; error = nil + defer { busy = false } + do { + let proof = try await attest(challenge) + switch try await service.phoneSignInConfirm(phone: phone, code: code, challenge: challenge, attestationJSON: proof) { + case .DeviceTokenResponse(let tokens): + try await accept(TokenPair(access: tokens.access, refresh: tokens.refresh)) + await authenticationFeedback(generation: feedbackGeneration) + case .MFAChallengeResponse(let challenge): + try await beginMFA(challenge, generation: feedbackGeneration) + case .MFACompleted(let completed): + try await complete(completed, generation: feedbackGeneration) } } catch { - fail(error) + if APIError.code(error) == "device_superadmin_not_supported" { refuse(error, generation: feedbackGeneration); return } if identityGeneration == feedbackGeneration, !APIError.isCancellation(error) { feedback = .init(.error) } + throw error + } + } + + private func beginMFA(_ challenge: Components.Schemas.MFAChallengeResponse, generation: Int) async throws { + mfa = challenge + mfaExpires = Date().addingTimeInterval(TimeInterval(challenge.expires_in)) + if challenge.enrollment_required { enrollment = try await service.enroll(challenge: challenge.challenge) } + stage = .mfa + if identityGeneration == generation { feedback = .init(.selection) } + } + + private func complete(_ result: Components.Schemas.MFACompleted, generation: Int) async throws { + guard let pair = NativeDeviceCompletion.tokens(access: result.access, refresh: result.refresh, grant: result.device_grant, surface: result.surface) else { + throw missingDeviceSession + } + recoveryCodes = result.recovery_codes ?? [] + try await accept(pair) + if !recoveryCodes.isEmpty { stage = .recoveryCodes } + await authenticationFeedback(generation: generation) + } + + private var expiredSignIn: APIError { APIError(status: 0, code: "challenge_expired", detail: String(localized: "Your sign-in expired. Try again.")) } + private var missingDeviceSession: APIError { + APIError(status: 0, code: "native_session_missing", detail: String(localized: "This server did not provide a device session. Contact your makerspace.")) + } + + /// Superadmin accounts are refused on devices; every other failure stays on its current screen. + private func refuse(_ failure: Error, generation: Int) { + if APIError.code(failure) == "device_superadmin_not_supported" { + mfa = nil; mfaExpires = nil; enrollment = nil; code = ""; password = ""; error = nil + stage = .webConsole + if identityGeneration == generation { feedback = .init(.error) } + return } + fail(failure) + if identityGeneration == generation, !APIError.isCancellation(failure) { feedback = .init(.error) } } + func leaveWebConsoleNotice() { guard stage == .webConsole else { return }; error = nil; stage = .signIn } func verify() async { guard !busy, let mfa, (retryAfter ?? .distantPast) <= Date() else { return } guard (mfaExpires ?? .distantPast) > Date() else { @@ -191,20 +299,8 @@ final class AuthState { defer { busy = false; code = "" } do { let result = try await service.verify(challenge: mfa.challenge, code: code, enrolling: mfa.enrollment_required) - let grantData = try result.device_grant.map { try JSONEncoder().encode($0) } - let grant = grantData.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } - guard let refresh = result.refresh, !refresh.isEmpty, !result.access.isEmpty, - result.surface != "verification_only", let grantID = grant?["id"] as? String, UUID(uuidString: grantID) != nil else { - throw APIError(status: 0, code: "native_session_missing", detail: String(localized: "This server did not provide a device session. Contact your makerspace.")) - } - recoveryCodes = result.recovery_codes ?? [] - try await accept(TokenPair(access: result.access, refresh: refresh)) - if !recoveryCodes.isEmpty { stage = .recoveryCodes } - await authenticationFeedback(generation: feedbackGeneration) - } catch { - fail(error) - if identityGeneration == feedbackGeneration, !APIError.isCancellation(error) { feedback = .init(.error) } - } + try await complete(result, generation: feedbackGeneration) + } catch { refuse(error, generation: feedbackGeneration) } } func confirmRecoveryCodes() { recoveryCodes = []; stage = user?.must_change_password == true ? .passwordChange : .signedIn } func backToSignIn() { @@ -257,6 +353,33 @@ final class AuthState { if revocationFailed { error = String(localized: "Signed out on this device. Server revocation could not be confirmed.") } } catch { await clear(); fail(error) } } + /// Revokes every browser session, device grant and push destination for this account, then clears + /// this device. A refused or offline request keeps the current session. + func signOutEverywhere() async { + guard stage == .signedIn, !busy, !endingSession, let accountID = user?.id, let credentials = acceptedCredentials else { return } + let generation = identityGeneration + busy = true; error = nil + do { + let service = service + _ = try await service.withSession(expectedCredentials: credentials) { try await service.signOutEverywhere() } + } catch { + busy = false + guard identityGeneration == generation, user?.id == accountID else { return } + if APIError.status(error) == 401 { await invalidateSession(message: APIError.display(error)) } else { fail(error) } + return + } + busy = false + guard identityGeneration == generation, user?.id == accountID else { return } + // The server already removed this device's grant and push destinations. + endingSession = true + detachNotifications() + clearLocalIdentity() + try? await service.refresh.install(nil) + try? await service.makerspace.select(nil, activeMemberships: []) + endingSession = false + error = String(localized: "Signed out on every device and browser.") + } + func changePassword(current: String, new: String, confirmation: String) async { guard stage == .passwordChange, !busy, (retryAfter ?? .distantPast) <= Date(), let accountID = user?.id, let credentials = acceptedCredentials else { return } guard !current.isEmpty, !new.isEmpty, new == confirmation else { diff --git a/SpaceWorks/AuthView.swift b/SpaceWorks/AuthView.swift index 495024f..b5720b2 100644 --- a/SpaceWorks/AuthView.swift +++ b/SpaceWorks/AuthView.swift @@ -21,7 +21,9 @@ struct AuthRoot: View { } else { switch auth.stage { case .restoring: ProgressView("Opening your makerspace…").frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.swBg) + case .updateRequired: UpdateRequiredView(auth: auth, state: state) case .signIn: SignInView(auth: auth, state: state) + case .webConsole: WebConsoleNoticeView(auth: auth) case .mfa, .recoveryCodes: MFAView(auth: auth) case .passwordChange: PasswordChangeView(auth: auth) case .securityCompletion: @@ -116,6 +118,7 @@ struct SignInView: View { @State private var showPassword = false @State private var passwordFocused = false @State private var accountForm: AccountAccessState? + @State private var phoneForm: PhoneSignInState? @Environment(\.dynamicTypeSize) private var typeSize var body: some View { GeometryReader { geometry in @@ -131,10 +134,16 @@ struct SignInView: View { Text("Couldn't load sign-in options. Try again.").foregroundStyle(Color.swMuted) if let error = auth.error { SWError(message: error) } Button("Try again") { Task { await auth.retryRestore() } }.buttonStyle(SWPrimary()) - } else if !auth.passwordEnabled { + } else if !auth.passwordEnabled && !auth.hasProviders { Text("Sign-in isn't available here").brandFont(.headline) Text("Ask your makerspace for help, or check the server address.").foregroundStyle(Color.swMuted) } else { + if auth.hasProviders { + ProviderSignInSection(auth: auth, phone: $phoneForm) + if auth.passwordEnabled { Text("or with your email").brandFont(.caption).foregroundStyle(Color.swMuted) } + } + if !auth.passwordEnabled, let error = auth.error { SWError(message: error) } + if auth.passwordEnabled { field("Email or username") { TextField("Email or username", text: $auth.username, prompt: Text("Email or username").foregroundColor(Color.swMuted)).textContentType(.username) @@ -155,6 +164,7 @@ struct SignInView: View { .buttonStyle(SWPrimary()).disabled(auth.busy || (auth.retryAfter ?? .distantPast) > context.date) .accessibilityIdentifier("sign-in") } + } if auth.signupEnabled { if typeSize.isAccessibilitySize { VStack(alignment: .leading, spacing: 8) { @@ -184,6 +194,8 @@ struct SignInView: View { }.background(Color.swBg).scrollDismissesKeyboard(.interactively).accessibilityIdentifier("sign-in-scroll") } .sheet(item: $accountForm) { form in AccountAccessView(form: form) { auth.username = form.email; accountForm = nil } } + .sheet(item: $phoneForm) { form in PhoneSignInView(form: form) } + .onChange(of: auth.stage) { _, stage in if stage != .signIn { phoneForm = nil } } } private func field(_ title: String, @ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 8) { diff --git a/SpaceWorks/DebugAuthFixtures.swift b/SpaceWorks/DebugAuthFixtures.swift index e68c2d8..98c6cf8 100644 --- a/SpaceWorks/DebugAuthFixtures.swift +++ b/SpaceWorks/DebugAuthFixtures.swift @@ -13,6 +13,11 @@ enum DebugAuthFixtures { static var tokens: String { "{\"access\":\"test-access\",\"refresh\":\"test-refresh\",\"user\":\(user),\"device_grant\":\(grant)}" } static let mfa = #"{"mfa_required":true,"enrollment_required":false,"challenge":"test-mfa-challenge-of-valid-length","expires_in":300}"# static let config = #"{"email_enabled":false,"public_image_max_bytes":10485760,"password_login":{"member":true,"staff":true},"social_auth":{}}"# + /// The schema's `native` discovery block: password and phone enabled, optional iOS minimum. + static func nativeConfig(minimum: String? = nil) -> String { + let value = minimum.map { "\"\($0)\"" } ?? "null" + return #"{"email_enabled":false,"public_image_max_bytes":10485760,"password_login":{"member":true},"native":{"min_app_version":{"android":null,"ios":"# + value + #"},"providers":{"password":true,"phone":true,"google":null,"apple":null,"oidc":[]}}}"# + } static let enrollment = #"{"secret":"TESTAUTHENTICATORKEY","provisioning_uri":"otpauth://totp/SpaceWorks:maker?secret=TESTAUTHENTICATORKEY&issuer=SpaceWorks","qr_data_url":""}"# } #endif diff --git a/SpaceWorks/NativeAttestor.swift b/SpaceWorks/NativeAttestor.swift index b069511..5905ba9 100644 --- a/SpaceWorks/NativeAttestor.swift +++ b/SpaceWorks/NativeAttestor.swift @@ -21,8 +21,8 @@ enum NativeAttestor { } let key = try await service.generateKey() let proof = try await service.attestKey(key, clientDataHash: Data(SHA256.hash(data: Data(challenge.utf8)))) - // PRD §7 specifies these attestation fields; the API deliberately accepts arbitrary JSON. - return try JSONSerialization.data(withJSONObject: ["key_id": key, "attestation": proof.base64EncodedString()]) + // PRD §7: base64 key ID and CBOR attestation, sent through the schema's open proof alternative. + return try JSONSerialization.data(withJSONObject: ["type": "app_attest", "key_id": key, "attestation": proof.base64EncodedString()]) #endif } } diff --git a/SpaceWorks/NativeSignInViews.swift b/SpaceWorks/NativeSignInViews.swift new file mode 100644 index 0000000..09cc5ff --- /dev/null +++ b/SpaceWorks/NativeSignInViews.swift @@ -0,0 +1,143 @@ +import SwiftUI +import SpaceWorksAPI + +/// The server's native minimum-version gate. Stored credentials are kept for the updated app. +struct UpdateRequiredView: View { + let auth: AuthState + let state: AppState + var body: some View { + ScrollView { + SWCard { + Image(systemName: "arrow.down.app").font(.largeTitle).foregroundStyle(Color.swAccentInk).accessibilityHidden(true) + Text("Update required").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) + Text("This version of the app is no longer supported by this server. Update the app to continue.").foregroundStyle(Color.swMuted) + if let minimum = auth.minimumVersion { + Text("Required version: \(minimum) or later").brandFont(.headline).accessibilityIdentifier("minimum-version") + } + if let error = auth.error { SWError(message: error) } + Button(auth.busy ? "Checking…" : "Check again") { Task { await auth.retryRestore() } } + .buttonStyle(SWPrimary()).disabled(auth.busy).accessibilityIdentifier("check-update") + if !state.locked { Button("Change server") { state.changeServer() }.frame(minHeight: 48).disabled(auth.busy) } + }.padding(24).frame(maxWidth: 560).frame(maxWidth: .infinity) + }.background(Color.swBg).accessibilityIdentifier("update-required") + } +} + +/// Superadmin accounts are refused on phones and tablets (`device_superadmin_not_supported`). +struct WebConsoleNoticeView: View { + let auth: AuthState + var body: some View { + ScrollView { + SWCard { + Image(systemName: "desktopcomputer").font(.largeTitle).foregroundStyle(Color.swAccentInk).accessibilityHidden(true) + Text("Use the web console").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) + Text("Superadmin accounts cannot sign in on a phone or tablet.").foregroundStyle(Color.swMuted) + Button("Back to sign in") { auth.leaveWebConsoleNotice() }.buttonStyle(SWPrimary()).accessibilityIdentifier("web-console-back") + }.padding(24).frame(maxWidth: 560).frame(maxWidth: .infinity) + }.background(Color.swBg).accessibilityIdentifier("web-console") + } +} + +/// Only providers published in `PublicConfig.native` render. Each launch requests a fresh challenge and nonce. +struct ProviderSignInSection: View { + let auth: AuthState + @Binding var phone: PhoneSignInState? + @State private var apple = AppleIdentity() + @State private var organization = NativeOIDCSignIn() + var body: some View { + VStack(alignment: .leading, spacing: 12) { + if let clientID = auth.googleClientID { + GeometryReader { geometry in + GoogleProviderButton(wide: geometry.size.width >= 312) { google(clientID) } + }.frame(height: 48).accessibilityIdentifier("provider-google") + } + if auth.appleEnabled { + AppleProviderButton { signInWithApple() }.frame(height: 48).accessibilityIdentifier("provider-apple") + } + ForEach(auth.organizationProviders, id: \.key) { provider in + Button { signIn(organization: provider) } label: { + Text("Continue with \(provider.display_name)").frame(maxWidth: .infinity, minHeight: 48) + }.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)) + .accessibilityIdentifier("provider-\(provider.key)") + } + if auth.phoneEnabled { + Button { phone = auth.phoneSignIn() } label: { + Label("Continue with phone", systemImage: "phone").frame(maxWidth: .infinity, minHeight: 48) + }.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)).accessibilityIdentifier("provider-phone") + } + }.disabled(auth.busy) + } + private func google(_ clientID: String) { + Task { + await auth.signIn(with: .google) { nonce in + guard let controller = NativePresentation.topController else { throw NativeIdentity.unavailable } + return try await NativeIdentity.google(clientID: clientID, nonce: nonce, presenting: controller) + } + } + } + private func signInWithApple() { + Task { + await auth.signIn(with: .apple) { nonce in + guard let window = NativePresentation.window else { throw NativeIdentity.unavailable } + return try await apple.credential(nonce: nonce, anchor: window) + } + } + } + private func signIn(organization provider: Components.Schemas.NativeOidc) { + guard let slug = NativeOIDCClient.slug(fromKey: provider.key) else { return } + Task { + await auth.signIn(with: .oidc(slug: slug)) { nonce in + guard let window = NativePresentation.window else { throw NativeIdentity.unavailable } + return try await organization.credential(issuer: provider.issuer, clientID: provider.client_id, nonce: nonce, anchor: window) + } + } + } +} + +struct PhoneSignInView: View { + @Bindable var form: PhoneSignInState + @Environment(\.dismiss) private var dismiss + @FocusState private var focused: Bool + var body: some View { + NavigationStack { + ScrollView { + SWCard { + Text("Continue with phone").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) + if form.step == .number { + Text("We'll send a verification code.").foregroundStyle(Color.swMuted) + Text("Phone number").brandFont(.headline) + TextField("Phone number", text: $form.number, prompt: Text(verbatim: "+91 98765 43210").foregroundColor(Color.swMuted)) + .textContentType(.telephoneNumber).keyboardType(.phonePad).focused($focused) + .padding(16).background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)).accessibilityIdentifier("phone-number") + Text("Include your country code.").brandFont(.caption).foregroundStyle(Color.swMuted) + if let error = form.error { SWError(message: error) } + Button(form.busy ? "Sending…" : "Send verification code") { Task { await form.send() } } + .buttonStyle(SWPrimary()).disabled(form.busy).accessibilityIdentifier("phone-send") + } else { + Text("Enter your verification code").brandFont(.title2, weight: .bold) + Text(verbatim: form.phone ?? "").foregroundStyle(Color.swMuted) + Text("Verification code").brandFont(.headline) + TextField("Verification code", text: $form.code) + .textContentType(.oneTimeCode).keyboardType(.numberPad).focused($focused) + .padding(16).background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)).accessibilityIdentifier("phone-code") + if let error = form.error { SWError(message: error) } + Button(form.busy ? "Verifying…" : "Verify") { Task { await form.verify() } } + .buttonStyle(SWPrimary()).disabled(form.busy).accessibilityIdentifier("phone-verify") + TimelineView(.periodic(from: .now, by: 1)) { tick in + Button(tick.date < form.resendAfter ? "Resend in \(max(1, Int(ceil(form.resendAfter.timeIntervalSince(tick.date)))))s" : "Send another code") { + Task { await form.send() } + }.frame(minHeight: 48).disabled(form.busy || tick.date < form.resendAfter) + } + Button("Change number") { form.changeNumber() }.frame(minHeight: 48).disabled(form.busy) + } + }.padding(24).frame(maxWidth: 560).frame(maxWidth: .infinity) + }.background(Color.swBg).scrollDismissesKeyboard(.interactively) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() }.frame(minHeight: 48).disabled(form.busy) } + ToolbarItem(placement: .keyboard) { Button("Done") { focused = false } } + } + }.interactiveDismissDisabled(form.busy) + } +} diff --git a/SpaceWorks/PhoneSignInState.swift b/SpaceWorks/PhoneSignInState.swift new file mode 100644 index 0000000..34412d4 --- /dev/null +++ b/SpaceWorks/PhoneSignInState.swift @@ -0,0 +1,78 @@ +import Foundation +import Observation +import SpaceWorksAPI + +/// Member-only phone sign-in with device delivery. The attestation challenge is requested with the +/// code and must still be valid when the code is confirmed; an expired pair restarts with a new code. +@MainActor @Observable +final class PhoneSignInState: Identifiable { + enum Step: Equatable { case number, code } + struct Started: Sendable { let challenge: String; let expires: Date } + let id = UUID() + var number = "" + var code = "" + private(set) var step = Step.number + private(set) var busy = false + private(set) var error: String? + private(set) var resendAfter = Date.distantPast + private(set) var phone: String? + private var started: Started? + private let begin: @MainActor (String) async throws -> Started + private let finish: @MainActor (String, String, String) async throws -> Void + private let now: () -> Date + + init(begin: @escaping @MainActor (String) async throws -> Started, + finish: @escaping @MainActor (String, String, String) async throws -> Void, now: @escaping () -> Date = Date.init) { + self.begin = begin; self.finish = finish; self.now = now + } + + /// E.164 with an explicit country code; spaces, dashes, dots and brackets are ignored. + static func normalized(_ input: String) -> String? { + let compact = input.filter { !" -().".contains($0) } + return compact.range(of: #"^\+[1-9][0-9]{6,14}$"#, options: .regularExpression) == nil ? nil : compact + } + + func send() async { + guard !busy, now() >= resendAfter else { return } + guard let phone = Self.normalized(step == .code ? (phone ?? number) : number) else { + error = String(localized: "Enter your phone number with its country code."); return + } + busy = true; error = nil + defer { busy = false } + do { + let value = try await begin(phone) + self.phone = phone; started = value; code = ""; step = .code + resendAfter = now().addingTimeInterval(60) + } catch { + if !APIError.isCancellation(error) { self.error = APIError.display(error) } + if let deadline = APIError.retryDeadline(error) { resendAfter = deadline } + } + } + + func verify() async { + guard !busy, let phone else { return } + let digits = code.filter { $0.isASCII && $0.isNumber } + guard digits.count == 6, digits.count == code.trimmingCharacters(in: .whitespaces).count else { + error = String(localized: "Enter the six-digit code."); return + } + guard let started, now() < started.expires else { expire(); return } + busy = true; error = nil + defer { busy = false } + do { try await finish(phone, digits, started.challenge) } + catch { + guard !APIError.isCancellation(error) else { return } + if ["phone_device_restart_required", "challenge_expired"].contains(APIError.code(error) ?? "") { expire() } + else { self.error = APIError.display(error) } + } + } + + func changeNumber() { + guard !busy else { return } + step = .number; code = ""; error = nil; started = nil + } + + private func expire() { + started = nil; code = ""; resendAfter = .distantPast + error = String(localized: "Your code expired. Send a new code.") + } +} diff --git a/SpaceWorks/Platform/NativeIdentity.swift b/SpaceWorks/Platform/NativeIdentity.swift index e283027..7c96f57 100644 --- a/SpaceWorks/Platform/NativeIdentity.swift +++ b/SpaceWorks/Platform/NativeIdentity.swift @@ -1,24 +1,37 @@ import AuthenticationServices +import CryptoKit import GoogleSignIn import SpaceWorksAPI import UIKit -/// Provider SDK seams. Live buttons remain gated until the server publishes native client configuration. +/// The provider result handed to the generated social sign-in operation. Tokens are never logged or persisted. +struct ProviderCredential: Sendable { + let idToken: String + var authorizationCode: String? = nil + var appleName: String? = nil +} + +/// Provider SDK seams. Buttons render only for providers the server publishes in `PublicConfig.native`. @MainActor enum NativeIdentity { - static func google(clientID: String, serverClientID: String?, nonce: String, presenting: UIViewController) async throws -> String { + static func google(clientID: String, nonce: String, presenting: UIViewController) async throws -> ProviderCredential { guard clientID.hasSuffix(".apps.googleusercontent.com"), !nonce.isEmpty else { throw unavailable } let prefix = String(clientID.dropLast(".apps.googleusercontent.com".count)) let reversed = "com.googleusercontent.apps.\(prefix)" let registrations = Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") as? [[String: Any]] ?? [] guard registrations.contains(where: { ($0["CFBundleURLSchemes"] as? [String])?.contains(reversed) == true }) else { throw unavailable } let provider = GIDSignIn.sharedInstance - provider.configuration = GIDConfiguration(clientID: clientID, serverClientID: serverClientID) + // The server verifies the iOS client ID as the token audience, so no server client ID is set. + provider.configuration = GIDConfiguration(clientID: clientID) // The app owns the device grant. Avoid retaining an independent Google account session. defer { provider.signOut() } - let result = try await provider.signIn(withPresenting: presenting, hint: nil, additionalScopes: nil, nonce: nonce) - try Task.checkCancellation() - guard let token = result.user.idToken?.tokenString, !token.isEmpty else { throw unavailable } - return token + do { + let result = try await provider.signIn(withPresenting: presenting, hint: nil, additionalScopes: nil, nonce: nonce) + try Task.checkCancellation() + guard let token = result.user.idToken?.tokenString, !token.isEmpty else { throw unavailable } + return ProviderCredential(idToken: token) + } catch let error as GIDSignInError where error.code == .canceled { + throw CancellationError() + } } static func handleGoogleCallback(_ url: URL) -> Bool { GIDSignIn.sharedInstance.handle(url) } nonisolated static var unavailable: APIError { @@ -26,18 +39,32 @@ import UIKit } } +/// The foreground scene's key window and its top presented controller. +@MainActor enum NativePresentation { + static var window: UIWindow? { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + let active = scenes.first { $0.activationState == .foregroundActive } ?? scenes.first + return active?.windows.first { $0.isKeyWindow } ?? active?.windows.first + } + static var topController: UIViewController? { + var controller = window?.rootViewController + while let presented = controller?.presentedViewController { controller = presented } + return controller + } +} + @MainActor final class AppleIdentity: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding { - private var continuation: CheckedContinuation? + private var continuation: CheckedContinuation? private var anchor: ASPresentationAnchor? private var controller: ASAuthorizationController? - func token(nonce: String, anchor: ASPresentationAnchor) async throws -> String { + func credential(nonce: String, anchor: ASPresentationAnchor) async throws -> ProviderCredential { guard !nonce.isEmpty, continuation == nil else { throw NativeIdentity.unavailable } self.anchor = anchor return try await withCheckedThrowingContinuation { continuation in self.continuation = continuation let request = ASAuthorizationAppleIDProvider().createRequest() request.requestedScopes = [.fullName, .email] - // Match the server-issued nonce contract also consumed by the existing frontend. + // The server accepts the issued nonce verbatim (or its SHA-256) in the identity token. request.nonce = nonce let controller = ASAuthorizationController(authorizationRequests: [request]) self.controller = controller @@ -52,17 +79,25 @@ import UIKit let data = credential.identityToken, let token = String(data: data, encoding: .utf8), !token.isEmpty else { finish(.failure(NativeIdentity.unavailable)); return } - finish(.success(token)) + // Apple shares the name only on first authorization; the code lets the server revoke on deletion. + let name = credential.fullName.map { PersonNameComponentsFormatter().string(from: $0) }?.trimmingCharacters(in: .whitespacesAndNewlines) + let code = credential.authorizationCode.flatMap { String(data: $0, encoding: .utf8) } + finish(.success(ProviderCredential(idToken: token, authorizationCode: code?.isEmpty == false ? code : nil, + appleName: name?.isEmpty == false ? String(name!.prefix(200)) : nil))) + } + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + if (error as? ASAuthorizationError)?.code == .canceled { finish(.failure(CancellationError())) } + else { finish(.failure(error)) } } - func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { finish(.failure(error)) } - private func finish(_ result: Result) { + private func finish(_ result: Result) { let pending = continuation; continuation = nil; controller = nil; anchor = nil pending?.resume(with: result) } } -/// ASWebAuthenticationSession is the sole browser-auth surface. The backend native OIDC callback is deferred. +/// ASWebAuthenticationSession is the sole browser surface: an ephemeral system session, no web cookies. @MainActor final class NativeOIDCBrowser: NSObject, ASWebAuthenticationPresentationContextProviding { + static let redirectURI = "spaceworks://auth/callback" private var session: ASWebAuthenticationSession? private var anchor: ASPresentationAnchor? func authorize(url: URL, expectedState: String, anchor: ASPresentationAnchor) async throws -> String { @@ -71,7 +106,10 @@ import UIKit defer { session = nil; self.anchor = nil } let callback: URL = try await withCheckedThrowingContinuation { continuation in let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "spaceworks") { callback, error in - if let error { continuation.resume(throwing: error) } + if let error { + let cancelled = (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin + continuation.resume(throwing: cancelled ? CancellationError() : error) + } else if let callback { continuation.resume(returning: callback) } else { continuation.resume(throwing: NativeIdentity.unavailable) } } diff --git a/SpaceWorks/Platform/NativeOIDC.swift b/SpaceWorks/Platform/NativeOIDC.swift new file mode 100644 index 0000000..8afba43 --- /dev/null +++ b/SpaceWorks/Platform/NativeOIDC.swift @@ -0,0 +1,114 @@ +import CryptoKit +import Foundation +import SpaceWorksAPI +import UIKit + +/// Organization OIDC through the provider's own public client: authorization code with PKCE in an +/// ephemeral system browser (RFC 8252). Discovery and the code exchange go only to the identity +/// provider issuer the server publishes, never to the Space Works API, and carry no app credentials. +/// The resulting ID token is then exchanged for a device grant through the generated social operation. +struct NativeOIDCClient: Sendable { + typealias Fetch = @Sendable (URLRequest) async throws -> (Data, Int) + struct Endpoints: Equatable, Sendable { let authorization: URL; let token: URL } + struct Authorization: Equatable, Sendable { let url: URL; let state: String; let verifier: String } + let fetch: Fetch + + static let live = NativeOIDCClient { request in + let configuration = URLSessionConfiguration.ephemeral + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + configuration.urlCredentialStorage = nil + configuration.urlCache = nil + configuration.timeoutIntervalForRequest = 20 + let session = URLSession(configuration: configuration, delegate: NoRedirectDelegate(), delegateQueue: nil) + defer { session.finishTasksAndInvalidate() } + let (data, response) = try await session.data(for: request) + return (data, (response as? HTTPURLResponse)?.statusCode ?? 0) + } + + /// The configured provider slug, from the published `oidc:{slug}` key. + static func slug(fromKey key: String) -> String? { + guard key.hasPrefix("oidc:") else { return nil } + let slug = String(key.dropFirst("oidc:".count)) + return slug.range(of: #"^[A-Za-z0-9_-]{1,64}$"#, options: .regularExpression) == nil ? nil : slug + } + + static func secureURL(_ value: String) -> URL? { + guard let parts = URLComponents(string: value), parts.scheme == "https", let host = parts.host, !host.isEmpty, + parts.user == nil, parts.password == nil, parts.fragment == nil, let url = parts.url else { return nil } + return url + } + + func discover(issuer: String) async throws -> Endpoints { + guard Self.secureURL(issuer) != nil, URLComponents(string: issuer)?.query == nil, + let url = URL(string: (issuer.hasSuffix("/") ? String(issuer.dropLast()) : issuer) + "/.well-known/openid-configuration") else { + throw NativeIdentity.unavailable + } + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + let (data, status) = try await fetch(request) + guard status == 200, let document = try JSONSerialization.jsonObject(with: data) as? [String: Any], + document["issuer"] as? String == issuer, + let authorization = (document["authorization_endpoint"] as? String).flatMap(Self.secureURL), + let token = (document["token_endpoint"] as? String).flatMap(Self.secureURL) else { throw NativeIdentity.unavailable } + return Endpoints(authorization: authorization, token: token) + } + + static func authorization(_ endpoints: Endpoints, clientID: String, nonce: String, + random: (Int) -> Data = Self.randomBytes) -> Authorization? { + guard !clientID.isEmpty, !nonce.isEmpty, var parts = URLComponents(url: endpoints.authorization, resolvingAgainstBaseURL: false) else { return nil } + let state = base64URL(random(16)) + let verifier = base64URL(random(32)) + let challenge = base64URL(Data(SHA256.hash(data: Data(verifier.utf8)))) + parts.queryItems = (parts.queryItems ?? []) + [ + .init(name: "response_type", value: "code"), .init(name: "client_id", value: clientID), + .init(name: "redirect_uri", value: NativeOIDCBrowser.redirectURI), .init(name: "scope", value: "openid email profile"), + .init(name: "state", value: state), .init(name: "nonce", value: nonce), + .init(name: "code_challenge", value: challenge), .init(name: "code_challenge_method", value: "S256")] + guard let url = parts.url else { return nil } + return Authorization(url: url, state: state, verifier: verifier) + } + + func exchange(_ endpoints: Endpoints, clientID: String, code: String, verifier: String) async throws -> String { + var request = URLRequest(url: endpoints.token) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = Self.form(["grant_type": "authorization_code", "code": code, "redirect_uri": NativeOIDCBrowser.redirectURI, + "client_id": clientID, "code_verifier": verifier]) + let (data, status) = try await fetch(request) + guard status == 200, let object = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let token = object["id_token"] as? String, !token.isEmpty else { throw NativeIdentity.unavailable } + return token + } + + static func form(_ values: [String: String]) -> Data { + var allowed = CharacterSet.alphanumerics; allowed.insert(charactersIn: "-._~") + let body = values.keys.sorted().map { key in + key + "=" + (values[key]!.addingPercentEncoding(withAllowedCharacters: allowed) ?? "") + }.joined(separator: "&") + return Data(body.utf8) + } + static func base64URL(_ data: Data) -> String { + data.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + /// The system generator is cryptographically secure on Apple platforms. + static func randomBytes(_ count: Int) -> Data { + var generator = SystemRandomNumberGenerator() + return Data((0.. ProviderCredential { + let endpoints = try await client.discover(issuer: issuer) + guard let request = NativeOIDCClient.authorization(endpoints, clientID: clientID, nonce: nonce) else { throw NativeIdentity.unavailable } + let code = try await browser.authorize(url: request.url, expectedState: request.state, anchor: anchor) + return ProviderCredential(idToken: try await client.exchange(endpoints, clientID: clientID, code: code, verifier: request.verifier)) + } +} diff --git a/SpaceWorks/RootView.swift b/SpaceWorks/RootView.swift index 61c4be5..27bb52b 100644 --- a/SpaceWorks/RootView.swift +++ b/SpaceWorks/RootView.swift @@ -304,6 +304,7 @@ private struct MemberScreen: View { @State private var accountForm: AccountAccessState? @State private var replayIntro = false @State private var confirmSignOut = false + @State private var confirmSignOutEverywhere = false var body: some View { Group { if tab != .profile { MemberContent(state: member, tab: tab, name: auth.displayName, selectTab: { navigation.selected = $0 }, actions: actions, cart: cart, navigation: navigation, drafts: drafts, paymentsAvailable: true, guidanceBookingsEnabled: capabilities.bootstrap?.modules.contains("bookable_spaces") == true, openLink: { text in @@ -336,6 +337,8 @@ private struct MemberScreen: View { }.frame(minHeight: 48).accessibilityIdentifier("app-guide") Button("Show helpful tips") { member.replayGuidance() }.frame(minHeight: 48) Button("Sign out", role: .destructive) { confirmSignOut = true }.frame(minHeight: 48).disabled(auth.busy) + Button("Sign out everywhere", role: .destructive) { confirmSignOutEverywhere = true } + .frame(minHeight: 48).disabled(auth.busy).accessibilityIdentifier("sign-out-everywhere") } }.padding(24).frame(maxWidth: 960, alignment: .leading).frame(maxWidth: .infinity) } } @@ -346,6 +349,15 @@ private struct MemberScreen: View { Button("Sign out", role: .destructive) { member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); account?.invalidate(); Task { await auth.signOut() } } Button("Cancel", role: .cancel) {} } message: { Text("You'll need to sign in again to use this account.") } + .confirmationDialog("Sign out everywhere?", isPresented: $confirmSignOutEverywhere, titleVisibility: .visible) { + Button("Sign out everywhere", role: .destructive) { + Task { + await auth.signOutEverywhere() + if auth.stage != .signedIn { member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); account?.invalidate() } + } + } + Button("Cancel", role: .cancel) {} + } message: { Text("This signs you out on every device and browser.") } .toolbar { ToolbarItem(placement: .topBarTrailing) { Menu { diff --git a/SpaceWorks/SpaceWorksApp.swift b/SpaceWorks/SpaceWorksApp.swift index f155c15..5702b9f 100644 --- a/SpaceWorks/SpaceWorksApp.swift +++ b/SpaceWorks/SpaceWorksApp.swift @@ -67,13 +67,20 @@ final class PreviewHealthProtocol: URLProtocol { let arguments = ProcessInfo.processInfo.arguments let path = "/" + request.url!.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let failure = arguments.contains("--health-unavailable") && path == "/api/v1/health" - let code = failure ? 503 : 200 + let superadmin = arguments.contains("--superadmin") && path == "/api/v1/auth/device/login" + let code = failure ? 503 : superadmin ? 403 : 200 let body: String if failure { body = #"{"detail":"Try again later.","code":"service_unavailable"}"# } + else if superadmin { body = #"{"detail":"Use the web console.","code":"device_superadmin_not_supported"}"# } else if let member = PreviewMemberResponses.response(for: request) { body = member } else { switch path { - case "/api/v1/config": body = DebugAuthFixtures.config + case "/api/v1/config": + body = arguments.contains("--native-min-version") ? DebugAuthFixtures.nativeConfig(minimum: "99.0") + : arguments.contains("--native-providers") ? DebugAuthFixtures.nativeConfig() : DebugAuthFixtures.config + case "/api/v1/auth/phone/login/start": body = #"{"detail":"If the number can sign in, a code was sent."}"# + case "/api/v1/auth/phone/login/confirm": body = DebugAuthFixtures.tokens + case "/api/v1/auth/logout-all": body = #"{"web_sessions":1,"devices":2}"# case "/api/v1/bootstrap": body = DebugMemberFixtures.bootstrap case "/api/v1/auth/device/attestation-challenge": body = #"{"challenge":"fresh-test-challenge","expires_in":120}"# case "/api/v1/auth/device/login": body = arguments.contains("--mfa") ? DebugAuthFixtures.mfa : DebugAuthFixtures.tokens diff --git a/SpaceWorksTests/MemberActionsTests.swift b/SpaceWorksTests/MemberActionsTests.swift index 1435d55..975d23f 100644 --- a/SpaceWorksTests/MemberActionsTests.swift +++ b/SpaceWorksTests/MemberActionsTests.swift @@ -68,7 +68,7 @@ import SpaceWorksAPI await state.confirm(); XCTAssertNil(state.result) await state.check(); await repository.preview(.conflict); await state.confirm() XCTAssertNil(state.result); XCTAssertNil(state.availability); XCTAssertNotNil(state.error) - await repository.preview(.success); await state.check(); await state.confirm(); XCTAssertEqual(state.result?.status.value1, .confirmed) + await repository.preview(.success); await state.check(); await state.confirm(); XCTAssertEqual(state.result?.pending, false) } func testUnknownCustomQuestionAndMalformedNumbersFailClosed() throws { let form = MemberCustomForm(data: Data(#"[{"id":"q1","label":"How many?","type":"number","required":true,"options":[]}]"#.utf8)) diff --git a/SpaceWorksTests/NativeAuthTests.swift b/SpaceWorksTests/NativeAuthTests.swift new file mode 100644 index 0000000..d211bc7 --- /dev/null +++ b/SpaceWorksTests/NativeAuthTests.swift @@ -0,0 +1,231 @@ +import XCTest +import Foundation +import SpaceWorksAPI +@testable import SpaceWorks + +private final class SeenPaths: @unchecked Sendable { + private let lock = NSLock() + private var values: [String] = [] + private var bodies: [String: [String: Any]] = [:] + func record(_ request: URLRequest) { + let body = (try? JSONSerialization.jsonObject(with: requestBody(request))) as? [String: Any] + lock.lock(); values.append(request.url!.path); if let body { bodies[request.url!.path] = body }; lock.unlock() + } + var paths: [String] { lock.lock(); defer { lock.unlock() }; return values } + func body(_ path: String) -> [String: Any]? { lock.lock(); defer { lock.unlock() }; return bodies[path] } +} + +@MainActor final class NativeAuthTests: XCTestCase { + private let json = ["Content-Type": "application/json"] + static func config(minimum: String? = nil, password: Bool = true, phone: Bool = false, google: String? = nil, + apple: Bool = false, oidc: String = "") -> String { + let min = minimum.map { "\"\($0)\"" } ?? "null" + let googleValue = google.map { #"{"android_client_id":"","ios_client_id":"\#($0)"}"# } ?? "null" + let appleValue = apple ? #"{"enabled":true}"# : "null" + return #"{"email_enabled":false,"public_image_max_bytes":10485760,"password_login":{"member":true},"native":{"min_app_version":{"android":null,"ios":\#(min)},"providers":{"password":\#(password),"phone":\#(phone),"google":\#(googleValue),"apple":\#(appleValue),"oidc":[\#(oidc)]}}}"# + } + private func state(storage: MemoryTokens = MemoryTokens(), version: String = "1.4.0", seen: SeenPaths = SeenPaths(), + handler: @escaping (URLRequest) throws -> (Int, String)) -> AuthState { + StubProtocol.lock.lock() + StubProtocol.handler = { request in + for header in ["Cookie", "Origin", "Referer"] { XCTAssertNil(request.value(forHTTPHeaderField: header)) } + seen.record(request) + let (status, text) = try handler(request) + return (status, self.json, Data(text.utf8)) + } + StubProtocol.lock.unlock() + let config = DeviceSession.configuration(); config.protocolClasses = [StubProtocol.self] + return AuthState(origin: try! ServerOrigin("https://spaceworks.example"), session: DeviceSession.make(configuration: config), + settings: UserDefaults(suiteName: UUID().uuidString)!, storage: storage, appVersion: version, + attest: { _ in Data(#"{"type":"development"}"#.utf8) }) + } + private func common(_ request: URLRequest, config: String = NativeAuthTests.config(), user: String = DebugAuthFixtures.user) -> (Int, String)? { + switch request.url!.path { + case "/api/v1/config": return (200, config) + case "/api/v1/auth/device/attestation-challenge": return (200, #"{"challenge":"fresh-challenge","expires_in":120}"#) + case "/api/v1/auth/me": return (200, user) + default: return nil + } + } + + func testVersionGateComparesDottedNumbersAndIgnoresMalformedMinimums() { + XCTAssertFalse(AppVersion.isBelow("1.10.0", minimum: "1.9")) + XCTAssertTrue(AppVersion.isBelow("1.9", minimum: "1.10")) + XCTAssertFalse(AppVersion.isBelow("1.4", minimum: "1.4.0")) + XCTAssertTrue(AppVersion.isBelow("1.4.0", minimum: "1.4.1")) + XCTAssertFalse(AppVersion.isBelow("1.0", minimum: nil)) + XCTAssertFalse(AppVersion.isBelow("1.0", minimum: "2.0-beta"), "A malformed server minimum never blocks") + XCTAssertTrue(AppVersion.isBelow("", minimum: "1.0"), "An unreadable app version is treated as outdated") + } + + func testMinimumVersionGateStopsBeforeIdentityAndKeepsStoredCredentials() async throws { + let storage = MemoryTokens(TokenPair(access: "test-access", refresh: "test-refresh")) + let seen = SeenPaths() + var minimum = "2.0.0" + let auth = state(storage: storage, seen: seen) { request in + if let value = self.common(request, config: NativeAuthTests.config(minimum: minimum)) { return value } + XCTFail("Unexpected API call: \(request.url!)"); return (500, "{}") + } + await auth.restore() + XCTAssertEqual(auth.stage, .updateRequired); XCTAssertEqual(auth.minimumVersion, "2.0.0") + XCTAssertFalse(seen.paths.contains("/api/v1/auth/me"), "No identity request below the published minimum") + XCTAssertEqual(try storage.load(), TokenPair(access: "test-access", refresh: "test-refresh")) + minimum = "1.4" + await auth.retryRestore() + XCTAssertEqual(auth.stage, .signedIn) + } + + func testOnlyPublishedNativeProvidersAreOffered() async { + let organization = #"{"key":"oidc:campus","display_name":"Campus SSO","client_id":"campus-app","issuer":"https://id.example.test"},{"key":"github","display_name":"Ignored","client_id":"x","issuer":"https://x.example"}"# + let auth = state { request in + self.common(request, config: NativeAuthTests.config(password: false, phone: true, google: "1234.apps.googleusercontent.com", apple: true, oidc: organization)) ?? (500, "{}") + } + await auth.restore() + XCTAssertFalse(auth.passwordEnabled); XCTAssertTrue(auth.phoneEnabled); XCTAssertTrue(auth.appleEnabled) + XCTAssertEqual(auth.googleClientID, "1234.apps.googleusercontent.com") + XCTAssertEqual(auth.organizationProviders.map(\.key), ["oidc:campus"], "Only valid configured OIDC keys render") + let legacy = state { request in self.common(request, config: DebugAuthFixtures.config) ?? (500, "{}") } + await legacy.restore() + XCTAssertTrue(legacy.passwordEnabled); XCTAssertFalse(legacy.hasProviders, "A server without native config offers no native providers") + } + + func testSocialSignInBindsNonceChallengeAndAttestationThenInstallsTheDeviceGrant() async throws { + let storage = MemoryTokens(); let seen = SeenPaths() + let auth = state(storage: storage, seen: seen) { request in + if let value = self.common(request) { return value } + switch request.url!.path { + case "/api/v1/auth/social/nonce": return (200, #"{"nonce":"server-nonce","expires_in":300}"#) + case "/api/v1/auth/social/oidc/campus": + return (200, "{\"access\":\"social-access\",\"refresh\":\"social-refresh\",\"device_grant\":\(DebugAuthFixtures.grant),\"user\":{},\"outcome\":\"login\"}") + default: XCTFail("Unexpected API call: \(request.url!)"); return (500, "{}") + } + } + await auth.restore() + var issuedNonce: String? + await auth.signIn(with: .oidc(slug: "campus")) { nonce in issuedNonce = nonce; return ProviderCredential(idToken: "provider-id-token") } + XCTAssertEqual(issuedNonce, "server-nonce") + let nonce = try XCTUnwrap(seen.body("/api/v1/auth/social/nonce")) + XCTAssertEqual(nonce["provider"] as? String, "oidc:campus"); XCTAssertEqual(nonce["delivery"] as? String, "device") + XCTAssertEqual(nonce["client_platform"] as? String, "ios"); XCTAssertEqual(nonce["challenge"] as? String, "fresh-challenge") + XCTAssertEqual(nonce["surface"] as? String, "member") + let login = try XCTUnwrap(seen.body("/api/v1/auth/social/oidc/campus")) + XCTAssertEqual(login["id_token"] as? String, "provider-id-token"); XCTAssertEqual(login["nonce"] as? String, "server-nonce") + XCTAssertEqual((login["attestation"] as? [String: Any])?["type"] as? String, "development") + XCTAssertEqual(auth.stage, .signedIn) + XCTAssertEqual(try storage.load(), TokenPair(access: "social-access", refresh: "social-refresh")) + } + + func testSocialResultWithoutDeviceGrantNeverOpensASession() async throws { + let storage = MemoryTokens() + let auth = state(storage: storage) { request in + if let value = self.common(request) { return value } + if request.url!.path == "/api/v1/auth/social/nonce" { return (200, #"{"nonce":"server-nonce","expires_in":300}"#) } + return (200, #"{"access":"web-access","user":{},"outcome":"login"}"#) + } + await auth.restore() + await auth.signIn(with: .google) { _ in ProviderCredential(idToken: "provider-id-token") } + XCTAssertEqual(auth.stage, .signIn); XCTAssertNil(try storage.load()) + XCTAssertEqual(auth.error, "This server did not provide a device session. Contact your makerspace.") + } + + func testCancelledProviderLeavesSignInQuiet() async { + let auth = state { request in + if let value = self.common(request) { return value } + return (200, #"{"nonce":"server-nonce","expires_in":300}"#) + } + await auth.restore() + await auth.signIn(with: .apple) { _ in throw CancellationError() } + XCTAssertEqual(auth.stage, .signIn); XCTAssertNil(auth.error); XCTAssertNil(auth.feedback) + } + + func testSuperadminRefusalShowsTheWebConsoleNotice() async { + let auth = state { request in + if let value = self.common(request) { return value } + return (403, #"{"detail":"Superadmin accounts must use the web console.","code":"device_superadmin_not_supported"}"#) + } + await auth.restore(); auth.username = "root"; auth.password = "password-fixture" + await auth.signIn() + XCTAssertEqual(auth.stage, .webConsole); XCTAssertNil(auth.user); XCTAssertEqual(auth.password, "") + auth.leaveWebConsoleNotice() + XCTAssertEqual(auth.stage, .signIn) + } + + func testPhoneSignInBindsItsChallengeToTheCodeAndContinuesToMFA() async throws { + let seen = SeenPaths() + let auth = state(seen: seen) { request in + if let value = self.common(request, config: NativeAuthTests.config(phone: true)) { return value } + switch request.url!.path { + case "/api/v1/auth/phone/login/start": return (200, #"{"detail":"If the number can sign in, a code was sent."}"#) + case "/api/v1/auth/phone/login/confirm": return (200, DebugAuthFixtures.mfa) + default: XCTFail("Unexpected API call: \(request.url!)"); return (500, "{}") + } + } + await auth.restore() + let phone = auth.phoneSignIn() + phone.number = "98765 43210"; await phone.send() + XCTAssertEqual(phone.error, "Enter your phone number with its country code."); XCTAssertEqual(phone.step, .number) + phone.number = "+91 98765-43210"; await phone.send() + XCTAssertEqual(phone.step, .code); XCTAssertEqual(phone.phone, "+919876543210") + let start = try XCTUnwrap(seen.body("/api/v1/auth/phone/login/start")) + XCTAssertEqual(start["delivery"] as? String, "device"); XCTAssertEqual(start["client_platform"] as? String, "ios") + XCTAssertEqual(start["challenge"] as? String, "fresh-challenge"); XCTAssertNil(start["code"]) + phone.code = "12345"; await phone.verify(); XCTAssertEqual(phone.error, "Enter the six-digit code.") + phone.code = "123456"; await phone.verify() + let confirm = try XCTUnwrap(seen.body("/api/v1/auth/phone/login/confirm")) + XCTAssertEqual(confirm["phone"] as? String, "+919876543210"); XCTAssertEqual(confirm["code"] as? String, "123456") + XCTAssertEqual(confirm["challenge"] as? String, "fresh-challenge") + XCTAssertEqual((confirm["attestation"] as? [String: Any])?["type"] as? String, "development") + XCTAssertEqual(auth.stage, .mfa) + } + + func testExpiredPhoneChallengeRequiresANewCodeWithoutContactingTheServer() async { + var clock = Date(timeIntervalSince1970: 1_000) + var confirms = 0 + let phone = PhoneSignInState(begin: { _ in .init(challenge: "bound", expires: clock.addingTimeInterval(180)) }, + finish: { _, _, _ in confirms += 1 }, now: { clock }) + phone.number = "+15551234567"; await phone.send() + XCTAssertEqual(phone.step, .code) + clock = clock.addingTimeInterval(181) + phone.code = "123456"; await phone.verify() + XCTAssertEqual(confirms, 0) + XCTAssertEqual(phone.error, "Your code expired. Send a new code.") + XCTAssertLessThanOrEqual(phone.resendAfter, clock, "A new code can be requested immediately") + } + + func testStaffToolsRequireANativeStaffSessionAuthority() async { + for (authority, expected) in [("member", false), ("native_staff", true), ("staff", false)] { + let auth = state(storage: MemoryTokens(TokenPair(access: "test-access", refresh: "test-refresh"))) { request in + self.common(request, user: DebugAuthFixtures.user(memberships: [DebugAuthFixtures.membership], authority: authority)) ?? (500, "{}") + } + await auth.restore() + XCTAssertEqual(auth.stage, .signedIn) + XCTAssertEqual(auth.staffToolsAvailable, expected, authority) + } + } + + func testSignOutEverywhereClearsThisDeviceOnlyAfterTheServerConfirms() async throws { + var available = false + let storage = MemoryTokens(TokenPair(access: "test-access", refresh: "test-refresh")) + let auth = state(storage: storage) { request in + if let value = self.common(request) { return value } + XCTAssertEqual(request.url!.path, "/api/v1/auth/logout-all"); XCTAssertEqual(request.httpMethod, "POST") + XCTAssertNil(request.value(forHTTPHeaderField: "X-Makerspace-Id")) + return available ? (200, #"{"web_sessions":2,"devices":3}"#) : (503, #"{"detail":"Try again later.","code":"unavailable"}"#) + } + await auth.restore(); XCTAssertEqual(auth.stage, .signedIn) + await auth.signOutEverywhere() + XCTAssertEqual(auth.stage, .signedIn, "A failed request keeps the session"); XCTAssertNotNil(try storage.load()) + XCTAssertEqual(auth.error, "Try again later.") + available = true + await auth.signOutEverywhere() + XCTAssertEqual(auth.stage, .signIn); XCTAssertNil(auth.user); XCTAssertNil(try storage.load()) + XCTAssertEqual(auth.error, "Signed out on every device and browser.") + } + + func testAppAttestProofKeepsItsAppleTypeInTheOpenVerifierAlternative() throws { + let proof = Data(#"{"type":"app_attest","key_id":"a2V5","attestation":"Y2Jvcg=="}"#.utf8) + let payload = try DeviceAttestation.payload(proof) + let encoded = try JSONSerialization.jsonObject(with: JSONEncoder().encode(payload)) as? [String: String] + XCTAssertEqual(encoded, ["type": "app_attest", "key_id": "a2V5", "attestation": "Y2Jvcg=="]) + } +} diff --git a/SpaceWorksTests/NativeIdentityTests.swift b/SpaceWorksTests/NativeIdentityTests.swift index 1b18a17..e13df29 100644 --- a/SpaceWorksTests/NativeIdentityTests.swift +++ b/SpaceWorksTests/NativeIdentityTests.swift @@ -1,14 +1,73 @@ +import CryptoKit import XCTest import UIKit @testable import SpaceWorks +private final class OIDCRequests: @unchecked Sendable { + private let lock = NSLock() + private var values: [URLRequest] = [] + func append(_ request: URLRequest) { lock.lock(); values.append(request); lock.unlock() } + var all: [URLRequest] { lock.lock(); defer { lock.unlock() }; return values } +} + @MainActor final class NativeIdentityTests: XCTestCase { func testGoogleWithoutRegisteredBuildConfigurationFailsBeforeSDKLaunch() async { do { - _ = try await NativeIdentity.google(clientID: "", serverClientID: nil, nonce: "sample", presenting: UIViewController()) + _ = try await NativeIdentity.google(clientID: "", nonce: "sample", presenting: UIViewController()) XCTFail("Unconfigured provider must fail before presentation") } catch { XCTAssertEqual(error.localizedDescription, NativeIdentity.unavailable.localizedDescription) } } + func testOIDCProviderKeyYieldsOnlyAValidConfiguredSlug() { + XCTAssertEqual(NativeOIDCClient.slug(fromKey: "oidc:campus-sso"), "campus-sso") + XCTAssertNil(NativeOIDCClient.slug(fromKey: "google")) + XCTAssertNil(NativeOIDCClient.slug(fromKey: "oidc:../admin")) + XCTAssertNil(NativeOIDCClient.slug(fromKey: "oidc:")) + } + func testOIDCDiscoveryRequiresTheExactIssuerAndSecureEndpoints() async throws { + let issuer = "https://id.example.test/realms/makers" + func client(_ document: String, status: Int = 200) -> NativeOIDCClient { + NativeOIDCClient { request in + XCTAssertEqual(request.url?.absoluteString, issuer + "/.well-known/openid-configuration") + XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) + return (Data(document.utf8), status) + } + } + let valid = #"{"issuer":"https://id.example.test/realms/makers","authorization_endpoint":"https://id.example.test/auth","token_endpoint":"https://id.example.test/token"}"# + let endpoints = try await client(valid).discover(issuer: issuer) + XCTAssertEqual(endpoints.token.absoluteString, "https://id.example.test/token") + for document in [valid.replacingOccurrences(of: "realms/makers\"", with: "realms/other\""), + valid.replacingOccurrences(of: "https://id.example.test/token", with: "http://id.example.test/token")] { + do { _ = try await client(document).discover(issuer: issuer); XCTFail("Mismatched or insecure discovery must be refused") } + catch { XCTAssertEqual(APIError.code(error), "native_provider_unconfigured") } + } + do { _ = try await client(valid).discover(issuer: "http://id.example.test"); XCTFail("Insecure issuer must be refused before any request") } + catch { XCTAssertEqual(APIError.code(error), "native_provider_unconfigured") } + } + func testOIDCAuthorizationUsesPKCEStateAndTheServerNonce() throws { + let endpoints = NativeOIDCClient.Endpoints(authorization: URL(string: "https://id.example.test/auth?kc_idp_hint=campus")!, token: URL(string: "https://id.example.test/token")!) + let request = try XCTUnwrap(NativeOIDCClient.authorization(endpoints, clientID: "makers-app", nonce: "server-nonce") { Data(repeating: 7, count: $0) }) + let items = Dictionary(uniqueKeysWithValues: (URLComponents(url: request.url, resolvingAgainstBaseURL: false)?.queryItems ?? []).map { ($0.name, $0.value ?? "") }) + XCTAssertEqual(items["kc_idp_hint"], "campus", "Existing provider parameters are preserved") + XCTAssertEqual(items["response_type"], "code"); XCTAssertEqual(items["client_id"], "makers-app") + XCTAssertEqual(items["redirect_uri"], "spaceworks://auth/callback"); XCTAssertEqual(items["nonce"], "server-nonce") + XCTAssertEqual(items["state"], request.state); XCTAssertEqual(items["code_challenge_method"], "S256") + XCTAssertEqual(items["code_challenge"], NativeOIDCClient.base64URL(Data(SHA256.hash(data: Data(request.verifier.utf8))))) + XCTAssertFalse(request.verifier.contains("=") || request.verifier.contains("+") || request.verifier.contains("/")) + } + func testOIDCCodeExchangePostsTheVerifierAndReturnsOnlyTheIDToken() async throws { + let seen = OIDCRequests() + let client = NativeOIDCClient { request in + seen.append(request) + return (Data(#"{"access_token":"provider-access","id_token":"provider-id-token","token_type":"Bearer"}"#.utf8), 200) + } + let endpoints = NativeOIDCClient.Endpoints(authorization: URL(string: "https://id.example.test/auth")!, token: URL(string: "https://id.example.test/token")!) + let token = try await client.exchange(endpoints, clientID: "makers app", code: "abc/123", verifier: "verifier-value") + XCTAssertEqual(token, "provider-id-token") + let request = try XCTUnwrap(seen.all.first) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(String(decoding: request.httpBody ?? Data(), as: UTF8.self), + "client_id=makers%20app&code=abc%2F123&code_verifier=verifier-value&grant_type=authorization_code&redirect_uri=spaceworks%3A%2F%2Fauth%2Fcallback") + } func testOrganizationBrowserRejectsInsecureURLBeforePresentation() async { let browser = NativeOIDCBrowser() do { diff --git a/SpaceWorksUITests/NativeAuthFlowTests.swift b/SpaceWorksUITests/NativeAuthFlowTests.swift new file mode 100644 index 0000000..29416d1 --- /dev/null +++ b/SpaceWorksUITests/NativeAuthFlowTests.swift @@ -0,0 +1,89 @@ +import XCTest + +/// Live native sign-in journeys against the Debug-only URLProtocol fixture server. +final class NativeAuthFlowTests: XCTestCase { + override func setUp() { continueAfterFailure = false; XCUIDevice.shared.orientation = .portrait } + private func launch(_ extra: [String]) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["--ui-testing"] + extra + app.launchForDeviceProfile() + let server = app.textFields["server-url"] + XCTAssertTrue(server.waitForExistence(timeout: 10)) + NativeUIActions.tap(server, in: app) + server.typeText("https://ui-test.spaceworks.invalid\n") + return app + } + private func attach(_ name: String) { + let item = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); item.name = name; item.lifetime = .keepAlways; add(item) + } + private func signIn(_ app: XCUIApplication) { + let username = app.textFields["username"] + XCTAssertTrue(username.waitForExistence(timeout: 10)) + NativeUIActions.tap(username, in: app); username.typeText("maker\n") + app.secureTextFields["password"].typeText("fixture-password\n") + NativeUIActions.tap(app.buttons["sign-in"], in: app) + } + + func testServerMinimumVersionBlocksSignInAndOffersARecheck() { + let app = launch(["--native-min-version"]) + XCTAssertTrue(app.staticTexts["Update required"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["minimum-version"].exists) + XCTAssertFalse(app.textFields["username"].exists, "Sign-in stays unavailable below the minimum") + attach("Update required") + NativeUIActions.tap(app.buttons["check-update"], in: app) + XCTAssertTrue(app.staticTexts["Update required"].waitForExistence(timeout: 10)) + } + + func testPhoneSignInReachesMakerspaceSelection() { + let app = launch(["--native-providers"]) + let phoneButton = app.buttons["provider-phone"] + XCTAssertTrue(phoneButton.waitForExistence(timeout: 10)) + attach("Native sign-in methods") + NativeUIActions.tap(phoneButton, in: app) + let number = app.textFields["phone-number"] + XCTAssertTrue(number.waitForExistence(timeout: 10)) + NativeUIActions.tap(number, in: app) + number.typeText("+15551234567") + NativeUIActions.tap(app.buttons["phone-send"], in: app) + let code = app.textFields["phone-code"] + XCTAssertTrue(code.waitForExistence(timeout: 10)) + NativeUIActions.tap(code, in: app) + code.typeText("123456") + attach("Phone verification code") + NativeUIActions.tap(app.buttons["phone-verify"], in: app) + XCTAssertTrue(app.buttons["makerspace-7"].waitForExistence(timeout: 10)) + } + + func testSuperadminAccountIsSentToTheWebConsole() { + let app = launch(["--superadmin"]) + signIn(app) + XCTAssertTrue(app.staticTexts["Use the web console"].waitForExistence(timeout: 10)) + attach("Superadmin refusal") + NativeUIActions.tap(app.buttons["web-console-back"], in: app) + XCTAssertTrue(app.textFields["username"].waitForExistence(timeout: 10)) + } + + func testSignOutEverywhereReturnsToSignIn() { + let app = launch([]) + signIn(app) + let space = app.buttons["makerspace-7"] + XCTAssertTrue(space.waitForExistence(timeout: 10)); NativeUIActions.tap(space, in: app) + XCTAssertTrue(app.staticTexts["Welcome back, Maker."].waitForExistence(timeout: 10)) + if app.tabBars.buttons["Profile"].exists { app.tabBars.buttons["Profile"].tap() } + else { + let item = app.buttons["nav-Profile"] + if !item.waitForExistence(timeout: 3) { + app.navigationBars.buttons.matching(NSPredicate(format: "label ==[c] %@ OR label ==[c] %@", "Show Sidebar", "Toggle Sidebar")).firstMatch.tap() + } + XCTAssertTrue(item.waitForExistence(timeout: 10)); NativeUIActions.tap(item, in: app) + } + let everywhere = app.buttons["sign-out-everywhere"] + XCTAssertTrue(everywhere.waitForExistence(timeout: 10)) + NativeUIActions.tap(everywhere, in: app) + let confirmation = app.buttons.matching(identifier: "Sign out everywhere").allElementsBoundByIndex.first { $0.isHittable && $0.identifier != "sign-out-everywhere" } + ?? app.sheets.buttons["Sign out everywhere"].firstMatch + XCTAssertTrue(confirmation.waitForExistence(timeout: 5)); confirmation.tap() + XCTAssertTrue(app.textFields["username"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["Signed out on every device and browser."].exists) + } +} From 394271ebc87435267e99035b5dfed02bbbc46ac5 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:44:39 +0530 Subject: [PATCH 85/98] feat(d3): open live staff tools for native staff sessions Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- SpaceWorks/DebugAuthFixtures.swift | 5 +++ SpaceWorks/RootView.swift | 31 ++++++++++----- SpaceWorks/SpaceWorksApp.swift | 6 ++- SpaceWorks/StaffState.swift | 7 +++- SpaceWorksTests/NativeAuthTests.swift | 2 +- SpaceWorksUITests/NativeAuthFlowTests.swift | 44 ++++++++++++++++----- 6 files changed, 72 insertions(+), 23 deletions(-) diff --git a/SpaceWorks/DebugAuthFixtures.swift b/SpaceWorks/DebugAuthFixtures.swift index 98c6cf8..cdc5f7d 100644 --- a/SpaceWorks/DebugAuthFixtures.swift +++ b/SpaceWorks/DebugAuthFixtures.swift @@ -7,6 +7,11 @@ enum DebugAuthFixtures { static let membership = #"{"id":7,"slug":"demo-workshop","role":"member","role_id":1,"role_ids":[1],"role_slugs":["member"],"role_names":["Member"],"role_name":"Member","role_slug":"member","source":"membership","actions":[],"can_configure_machine_types":false,"is_machine_only":false,"can_refer":false,"can_verify":false,"verified_at":null,"referrals_enabled":false}"# static let secondMembership = membership.replacingOccurrences(of: #""id":7"#, with: #""id":8"#).replacingOccurrences(of: "demo-workshop", with: "community-lab") static var user: String { user(memberships: [membership, secondMembership]) } + /// A native staff session whose first membership grants handover and review actions. + static var staffUser: String { + let staff = membership.replacingOccurrences(of: #""actions":[]"#, with: #""actions":["accept_request","reject_request","assign_box","issue_request","return_request","upload_evidence"]"#) + return user(memberships: [staff, secondMembership], authority: "native_staff") + } static func user(memberships: [String], superuser: Bool = false, authority: String = "member") -> String { "{\"session_authority\":\"\(authority)\",\"id\":12,\"username\":\"member\",\"email\":\"member@example.invalid\",\"display_name\":\"Maker\",\"phone\":\"\",\"email_verified\":true,\"role\":\"member\",\"is_superuser\":\(superuser),\"must_change_password\":false,\"makerspaces\":[\(memberships.joined(separator: ","))]}" } diff --git a/SpaceWorks/RootView.swift b/SpaceWorks/RootView.swift index 27bb52b..9920f04 100644 --- a/SpaceWorks/RootView.swift +++ b/SpaceWorks/RootView.swift @@ -176,7 +176,8 @@ struct AppShell: View { var body: some View { if let membership = auth.selectedMembership, let origin = state.connectedOrigin { MemberWorkspace(state: state, auth: auth, origin: origin, id: membership.id, slug: membership.slug, linkRouter: linkRouter) - .id("\(origin.url.absoluteString).\(auth.user?.id ?? 0).\(membership.id)") + // Staff authority or action changes rebuild every scoped state from a fresh /auth/me. + .id("\(origin.url.absoluteString).\(auth.user?.id ?? 0).\(membership.id).\(auth.staffToolsAvailable).\(membership.actions.sorted().joined(separator: ","))") } } } @@ -196,6 +197,7 @@ private struct MemberWorkspace: View { @State private var capabilities: MemberCapabilities @State private var payments: PaymentsState @State private var account: AccountState? + @State private var staff: StaffState? let actions: LiveMemberActionsRepository @State private var columns = NavigationSplitViewVisibility.automatic @State private var preferredCompactColumn = NavigationSplitViewColumn.detail @@ -206,6 +208,12 @@ private struct MemberWorkspace: View { onUnauthorized: failureHandlers.unauthorized, onForbidden: failureHandlers.forbidden) self.actions = actions _account = State(initialValue: auth.accountState(makerspaceID: id)) + // Native staff authority plus at least one staff action of this membership; the server still enforces each call. + let membershipActions = Set(auth.selectedMembership?.actions ?? []) + _staff = State(initialValue: auth.staffToolsAvailable && auth.selectedMembership?.id == id && !membershipActions.isDisjoint(with: StaffState.toolActions) + ? StaffState(repository: LiveStaffRepository(service: auth.service, expectedCredentials: auth.acceptedCredentials, makerspaceID: id, origin: origin, + onUnauthorized: failureHandlers.unauthorized, onForbidden: failureHandlers.forbidden), + makerspaceID: id, actions: membershipActions, features: []) : nil) _payments = State(initialValue: PaymentsState(repository: LivePaymentRepository(service: auth.service, expectedCredentials: auth.acceptedCredentials, makerspaceID: id, origin: origin, onUnauthorized: failureHandlers.unauthorized, onForbidden: failureHandlers.forbidden))) _cart = State(initialValue: RequestCartState(repository: actions)) @@ -251,16 +259,17 @@ private struct MemberWorkspace: View { columns = width >= 1000 ? .all : .detailOnly } }.task { - auth.registerPrivateHolder(privateHolderOwner) { [member, cart, drafts, capabilities, navigation, payments, account] in - member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); account?.invalidate() + auth.registerPrivateHolder(privateHolderOwner) { [member, cart, drafts, capabilities, navigation, payments, account, staff] in + member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); account?.invalidate(); staff?.invalidate() } async let config: Void = capabilities.load(); await member.loadUnreadCount(); await config }.onDisappear { auth.unregisterPrivateHolder(privateHolderOwner) - member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); account?.invalidate() + member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); account?.invalidate(); staff?.invalidate() } .onChange(of: capabilities.selfService, initial: true) { _, _ in account?.updateCapabilities(selfService: capabilities.selfService, exports: capabilities.exports) } .onChange(of: capabilities.exports, initial: true) { _, _ in account?.updateCapabilities(selfService: capabilities.selfService, exports: capabilities.exports) } + .onChange(of: capabilities.bootstrap?.features, initial: true) { _, features in staff?.update(features: Set(features ?? [])) } .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await capabilities.load(); await member.loadHome(); await member.loadUnreadCount() } } } @@ -279,9 +288,9 @@ private struct MemberWorkspace: View { } private func destination(_ tab: AppTab) -> some View { NavigationStack(path: navigation.path(tab)) { - MemberScreen(state: state, auth: auth, member: member, tab: tab, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, payments: payments, account: account, linkRouter: linkRouter) + MemberScreen(state: state, auth: auth, member: member, tab: tab, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, payments: payments, account: account, staff: staff, linkRouter: linkRouter) .navigationDestination(for: MemberRoute.self) { route in - MemberRouteView(route: route, member: member, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, isDemo: false, payments: payments, account: account) { await auth.foreground() } + MemberRouteView(route: route, member: member, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, isDemo: false, payments: payments, staff: staff, account: account) { await auth.foreground() } } } } @@ -300,6 +309,7 @@ private struct MemberScreen: View { let capabilities: MemberCapabilities let payments: PaymentsState let account: AccountState? + let staff: StaffState? let linkRouter: MobileLinkRouter @State private var accountForm: AccountAccessState? @State private var replayIntro = false @@ -328,12 +338,13 @@ private struct MemberScreen: View { if capabilities.preferences { NavigationLink("Communication preferences", value: MemberRoute.preferences).frame(minHeight: 48) } if account != nil { NavigationLink("Account and security", value: MemberRoute.account).frame(minHeight: 48).accessibilityIdentifier("account-entry") } NavigationLink("Payments", value: MemberRoute.payments).frame(minHeight: 48).accessibilityIdentifier("payments-entry") + if staff != nil { NavigationLink("Staff tools", value: MemberRoute.staff).frame(minHeight: 48).accessibilityIdentifier("staff-tools") } if let notifications = auth.notifications { NavigationLink("Notifications") { NotificationSettingsView(state: notifications) }.frame(minHeight: 48).accessibilityIdentifier("notifications-entry") } Button("Show introduction") { replayIntro = true }.frame(minHeight: 48) NavigationLink("App guide") { - AppGuideView(serverLabel: state.connectedOrigin?.url.absoluteString, replayTips: member.replayGuidance) + AppGuideView(staffAvailable: staff != nil, serverLabel: state.connectedOrigin?.url.absoluteString, replayTips: member.replayGuidance) }.frame(minHeight: 48).accessibilityIdentifier("app-guide") Button("Show helpful tips") { member.replayGuidance() }.frame(minHeight: 48) Button("Sign out", role: .destructive) { confirmSignOut = true }.frame(minHeight: 48).disabled(auth.busy) @@ -346,14 +357,14 @@ private struct MemberScreen: View { .sheet(item: $accountForm) { form in AccountAccessView(form: form) { accountForm = nil } } .sheet(isPresented: $replayIntro) { IntroductionView { replayIntro = false } } .confirmationDialog("Sign out?", isPresented: $confirmSignOut, titleVisibility: .visible) { - Button("Sign out", role: .destructive) { member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); account?.invalidate(); Task { await auth.signOut() } } + Button("Sign out", role: .destructive) { member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); account?.invalidate(); staff?.invalidate(); Task { await auth.signOut() } } Button("Cancel", role: .cancel) {} } message: { Text("You'll need to sign in again to use this account.") } .confirmationDialog("Sign out everywhere?", isPresented: $confirmSignOutEverywhere, titleVisibility: .visible) { Button("Sign out everywhere", role: .destructive) { Task { await auth.signOutEverywhere() - if auth.stage != .signedIn { member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); account?.invalidate() } + if auth.stage != .signedIn { member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); account?.invalidate(); staff?.invalidate() } } } Button("Cancel", role: .cancel) {} @@ -365,7 +376,7 @@ private struct MemberScreen: View { Button(membership.slug) { guard auth.selectedID != membership.id else { return } linkRouter.cancelIntent() - member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); account?.invalidate() + member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); account?.invalidate(); staff?.invalidate() Task { await auth.select(membership.id) } } } diff --git a/SpaceWorks/SpaceWorksApp.swift b/SpaceWorks/SpaceWorksApp.swift index 5702b9f..498635d 100644 --- a/SpaceWorks/SpaceWorksApp.swift +++ b/SpaceWorks/SpaceWorksApp.swift @@ -84,7 +84,11 @@ final class PreviewHealthProtocol: URLProtocol { case "/api/v1/bootstrap": body = DebugMemberFixtures.bootstrap case "/api/v1/auth/device/attestation-challenge": body = #"{"challenge":"fresh-test-challenge","expires_in":120}"# case "/api/v1/auth/device/login": body = arguments.contains("--mfa") ? DebugAuthFixtures.mfa : DebugAuthFixtures.tokens - case "/api/v1/auth/me": body = arguments.contains("--temporary-password") ? DebugAuthFixtures.user.replacingOccurrences(of: "\"must_change_password\":false", with: "\"must_change_password\":true") : DebugAuthFixtures.user + case "/api/v1/auth/me": + body = arguments.contains("--native-staff") ? DebugAuthFixtures.staffUser + : arguments.contains("--temporary-password") ? DebugAuthFixtures.user.replacingOccurrences(of: "\"must_change_password\":false", with: "\"must_change_password\":true") : DebugAuthFixtures.user + case "/api/v1/admin/makerspace/7/pending-requests": + body = #"{"count":1,"next":null,"previous":null,"results":["# + DebugStaffFixtures.request + "]}" case "/api/v1/auth/mfa/verify": body = "{\"access\":\"test-access\",\"refresh\":\"test-refresh\",\"device_grant\":\(DebugAuthFixtures.grant),\"surface\":\"member\",\"user\":\(DebugAuthFixtures.user)}" case "/api/v1/auth/device/logout": body = #"{"detail":"Signed out."}"# case "/api/v1/auth/change-password": body = #"{"detail":"Password updated."}"# diff --git a/SpaceWorks/StaffState.swift b/SpaceWorks/StaffState.swift index fc0de87..208aaaa 100644 --- a/SpaceWorks/StaffState.swift +++ b/SpaceWorks/StaffState.swift @@ -9,7 +9,10 @@ final class StaffState { let repository: any StaffRepository let makerspaceID: Int let actions: Set - let features: Set + private(set) var features: Set + /// Membership actions that open a staff tool. Any one of them makes the staff entry available. + static let toolActions: Set = ["accept_request", "reject_request", "assign_box", "issue_request", "return_request", + "upload_evidence", "issue_direct_loan", "manage_events", "manage_bookings"] var queue = StaffQueue.pending private(set) var requests: [Components.Schemas.AdminRequest] = [] private(set) var directLoans: [Components.Schemas.DirectLoan] = [] @@ -42,6 +45,8 @@ final class StaffState { self.actions = actions; self.features = features } var isDemo: Bool { repository.isDemo } + /// Tenant features arrive with the bootstrap after the workspace opens. + func update(features: Set) { if active { self.features = features } } var isActive: Bool { active } var canRequest: Bool { active && !busy && Date() >= retryAfter } func can(_ action: String) -> Bool { active && actions.contains(action) } diff --git a/SpaceWorksTests/NativeAuthTests.swift b/SpaceWorksTests/NativeAuthTests.swift index d211bc7..197c9e4 100644 --- a/SpaceWorksTests/NativeAuthTests.swift +++ b/SpaceWorksTests/NativeAuthTests.swift @@ -17,7 +17,7 @@ private final class SeenPaths: @unchecked Sendable { @MainActor final class NativeAuthTests: XCTestCase { private let json = ["Content-Type": "application/json"] - static func config(minimum: String? = nil, password: Bool = true, phone: Bool = false, google: String? = nil, + nonisolated static func config(minimum: String? = nil, password: Bool = true, phone: Bool = false, google: String? = nil, apple: Bool = false, oidc: String = "") -> String { let min = minimum.map { "\"\($0)\"" } ?? "null" let googleValue = google.map { #"{"android_client_id":"","ios_client_id":"\#($0)"}"# } ?? "null" diff --git a/SpaceWorksUITests/NativeAuthFlowTests.swift b/SpaceWorksUITests/NativeAuthFlowTests.swift index 29416d1..8257268 100644 --- a/SpaceWorksUITests/NativeAuthFlowTests.swift +++ b/SpaceWorksUITests/NativeAuthFlowTests.swift @@ -63,20 +63,44 @@ final class NativeAuthFlowTests: XCTestCase { XCTAssertTrue(app.textFields["username"].waitForExistence(timeout: 10)) } - func testSignOutEverywhereReturnsToSignIn() { - let app = launch([]) - signIn(app) + private func openProfile(_ app: XCUIApplication) { let space = app.buttons["makerspace-7"] XCTAssertTrue(space.waitForExistence(timeout: 10)); NativeUIActions.tap(space, in: app) XCTAssertTrue(app.staticTexts["Welcome back, Maker."].waitForExistence(timeout: 10)) - if app.tabBars.buttons["Profile"].exists { app.tabBars.buttons["Profile"].tap() } - else { - let item = app.buttons["nav-Profile"] - if !item.waitForExistence(timeout: 3) { - app.navigationBars.buttons.matching(NSPredicate(format: "label ==[c] %@ OR label ==[c] %@", "Show Sidebar", "Toggle Sidebar")).firstMatch.tap() - } - XCTAssertTrue(item.waitForExistence(timeout: 10)); NativeUIActions.tap(item, in: app) + if app.tabBars.buttons["Profile"].exists { app.tabBars.buttons["Profile"].tap(); return } + let item = app.buttons["nav-Profile"] + let readiness = NativeUIActions.readiness(in: app) + if XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: readiness, object: item)], timeout: 3) != .completed { + let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label ==[c] %@ OR label ==[c] %@", "Show Sidebar", "Toggle Sidebar")).firstMatch + XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: readiness, object: sidebar)], timeout: 10), .completed) + sidebar.tap() + XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: readiness, object: item)], timeout: 10), .completed) } + NativeUIActions.tap(item, in: app) + } + + func testNativeStaffSessionOpensTheLiveStaffQueue() { + let app = launch(["--native-staff"]) + signIn(app); openProfile(app) + let tools = app.buttons["staff-tools"] + XCTAssertTrue(tools.waitForExistence(timeout: 10)) + NativeUIActions.tap(tools, in: app) + let requests = app.buttons["staff-requests"] + XCTAssertTrue(requests.waitForExistence(timeout: 10)); NativeUIActions.tap(requests, in: app) + XCTAssertTrue(app.buttons["staff-request-601"].waitForExistence(timeout: 10), "The live pending queue comes from the generated admin operation") + attach("Live staff queue") + } + + func testMemberSessionHasNoStaffTools() { + let app = launch([]) + signIn(app); openProfile(app) + XCTAssertTrue(app.buttons["sign-out-everywhere"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.buttons["staff-tools"].exists) + } + + func testSignOutEverywhereReturnsToSignIn() { + let app = launch([]) + signIn(app); openProfile(app) let everywhere = app.buttons["sign-out-everywhere"] XCTAssertTrue(everywhere.waitForExistence(timeout: 10)) NativeUIActions.tap(everywhere, in: app) From 986728355275369b5ff8c6df1b21cd2abf54efd6 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:44:40 +0530 Subject: [PATCH 86/98] feat(d4): whole-account deletion with blockers, fresh proof and typed confirmation Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- .../SpaceWorksAPI/DeletionOperations.swift | 36 +++ SpaceWorks/AccountDeletion.swift | 267 ++++++++++++++++++ SpaceWorks/AccountDeletionView.swift | 139 +++++++++ SpaceWorks/AccountState.swift | 5 + SpaceWorks/AccountViews.swift | 20 +- SpaceWorks/AuthState.swift | 60 +++- SpaceWorks/RootView.swift | 5 +- SpaceWorksTests/AccountDeletionTests.swift | 183 ++++++++++++ 8 files changed, 701 insertions(+), 14 deletions(-) create mode 100644 Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/DeletionOperations.swift create mode 100644 SpaceWorks/AccountDeletion.swift create mode 100644 SpaceWorks/AccountDeletionView.swift create mode 100644 SpaceWorksTests/AccountDeletionTests.swift diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/DeletionOperations.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/DeletionOperations.swift new file mode 100644 index 0000000..28b2a9e --- /dev/null +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/DeletionOperations.swift @@ -0,0 +1,36 @@ +import Foundation +import OpenAPIRuntime + +/// Whole-account deletion and its server-owned recent-identity proof. These are global account +/// operations: callers run them inside `withSession`, without a makerspace header. +extension SpaceWorksService { + public func accountDeletionPreview() async throws -> Components.Schemas.AccountDeletionPreview { + try await client.api_v1_auth_account_deletion_retrieve().ok.body.json + } + /// The server rechecks every blocker. A conflict (409) is surfaced as an APIError; refetch the preview. + public func deleteAccount() async throws -> Components.Schemas.AccountDeletionResult { + try await client.api_v1_auth_account_deletion_create(.init(body: .json(.init(confirm: .DELETE)))).accepted.body.json + } + public func reauthenticationStatus() async throws -> Components.Schemas.ReauthenticationStatus { + try await client.api_v1_auth_reauth_retrieve().ok.body.json + } + public func reauthenticate(password: String, code: String?) async throws -> Components.Schemas.ReauthenticationResult { + try await client.api_v1_auth_reauth_create(.init(body: .json(.init(password: password, code: code?.isEmpty == false ? code : nil)))).ok.body.json + } + /// A separate reauthentication OTP for phone-only accounts, not phone link or login verification. + public func reauthenticationPhoneStart() async throws -> Components.Schemas.ReauthenticationPhoneStarted { + try await client.api_v1_auth_reauth_phone_start_create().ok.body.json + } + public func reauthenticationPhoneConfirm(token: String, code: String) async throws -> Components.Schemas.ReauthenticationResult { + try await client.api_v1_auth_reauth_phone_confirm_create(.init(body: .json(.init(token: token, code: code)))).ok.body.json + } + public func reauthenticationSocialStart(provider: String) async throws -> Components.Schemas.ReauthenticationSocialStarted { + try await client.api_v1_auth_reauth_social_start_create(.init(body: .json(.init(provider: provider)))).ok.body.json + } + public func reauthenticationSocialComplete(state: String, nonce: String, idToken: String) async throws -> Components.Schemas.ReauthenticationResult { + try await client.api_v1_auth_reauth_social_complete_create(.init(body: .json(.init(state: state, nonce: nonce, id_token: idToken)))).ok.body.json + } + public func reauthenticationFactor(token: String, code: String) async throws -> Components.Schemas.ReauthenticationResult { + try await client.api_v1_auth_reauth_social_factor_create(.init(body: .json(.init(token: token, code: code)))).ok.body.json + } +} diff --git a/SpaceWorks/AccountDeletion.swift b/SpaceWorks/AccountDeletion.swift new file mode 100644 index 0000000..502b62b --- /dev/null +++ b/SpaceWorks/AccountDeletion.swift @@ -0,0 +1,267 @@ +import Foundation +import Observation +import SpaceWorksAPI + +/// Whole-account deletion across every makerspace on this server. Leaving one makerspace is separate. +protocol AccountDeletionRepository: Sendable { + func preview() async throws -> Components.Schemas.AccountDeletionPreview + func status() async throws -> Components.Schemas.ReauthenticationStatus + func reauthenticate(password: String, code: String?) async throws -> Components.Schemas.ReauthenticationResult + func startPhone() async throws -> Components.Schemas.ReauthenticationPhoneStarted + func confirmPhone(token: String, code: String) async throws -> Components.Schemas.ReauthenticationResult + func startSocial(provider: String) async throws -> Components.Schemas.ReauthenticationSocialStarted + func completeSocial(state: String, nonce: String, idToken: String) async throws -> Components.Schemas.ReauthenticationResult + func factor(token: String, code: String) async throws -> Components.Schemas.ReauthenticationResult + func delete() async throws -> Components.Schemas.AccountDeletionResult +} + +struct LiveAccountDeletionRepository: AccountDeletionRepository { + let service: SpaceWorksService + let expectedCredentials: CredentialSnapshot + var onUnauthorized: @Sendable (String) async -> Void = { _ in } + + /// Identity proofs answer 401 for a wrong credential. Only a failed identity probe ends the session. + private func checked(proof: Bool = false, _ work: @Sendable () async throws -> T) async throws -> T { + guard await service.refresh.isSameInstallation(expectedCredentials) else { throw CancellationError() } + do { return try await service.withSession(expectedCredentials: expectedCredentials, operation: work) } + catch { + if Task.isCancelled || APIError.isCancellation(error) { throw error } + guard await service.refresh.isSameInstallation(expectedCredentials) else { throw CancellationError() } + let empty = await service.refresh.current() == nil + if proof, APIError.status(error) == 401, !empty { + do { _ = try await service.withSession(expectedCredentials: expectedCredentials) { try await service.me() } } + catch { + if Task.isCancelled || APIError.isCancellation(error) { throw error } + if APIError.status(error) == 401 || (await service.refresh.current() == nil) { await onUnauthorized(APIError.display(error)) } + throw error + } + throw APIError(status: 0, code: "identity_verification_failed", detail: APIError.display(error), retryAfter: APIError.retryDeadline(error)) + } + if APIError.status(error) == 401 || empty { await onUnauthorized(APIError.display(error)) } + throw error + } + } + func preview() async throws -> Components.Schemas.AccountDeletionPreview { try await checked { try await service.accountDeletionPreview() } } + func status() async throws -> Components.Schemas.ReauthenticationStatus { try await checked { try await service.reauthenticationStatus() } } + func reauthenticate(password: String, code: String?) async throws -> Components.Schemas.ReauthenticationResult { + try await checked(proof: true) { try await service.reauthenticate(password: password, code: code) } + } + func startPhone() async throws -> Components.Schemas.ReauthenticationPhoneStarted { try await checked { try await service.reauthenticationPhoneStart() } } + func confirmPhone(token: String, code: String) async throws -> Components.Schemas.ReauthenticationResult { + try await checked(proof: true) { try await service.reauthenticationPhoneConfirm(token: token, code: code) } + } + func startSocial(provider: String) async throws -> Components.Schemas.ReauthenticationSocialStarted { + try await checked { try await service.reauthenticationSocialStart(provider: provider) } + } + func completeSocial(state: String, nonce: String, idToken: String) async throws -> Components.Schemas.ReauthenticationResult { + try await checked(proof: true) { try await service.reauthenticationSocialComplete(state: state, nonce: nonce, idToken: idToken) } + } + func factor(token: String, code: String) async throws -> Components.Schemas.ReauthenticationResult { + try await checked(proof: true) { try await service.reauthenticationFactor(token: token, code: code) } + } + func delete() async throws -> Components.Schemas.AccountDeletionResult { try await checked { try await service.deleteAccount() } } +} + +extension AccountState { + func withDeletion(origin: ServerOrigin, memberships: Set, make: @escaping @MainActor () -> AccountDeletionState?) -> AccountState { + deletionOrigin = origin; deletionMemberships = memberships; makeDeletion = make + return self + } +} + +@MainActor @Observable +final class AccountDeletionState { + enum Stage: Equatable { case overview, blockers, verify, factor, confirm, deleted } + enum Method: Hashable, Identifiable { + case password, phone, social(String) + var id: String { switch self { case .password: return "password"; case .phone: return "phone"; case .social(let key): return key } } + } + private(set) var stage = Stage.overview + private(set) var preview: Components.Schemas.AccountDeletionPreview? + private(set) var status: Components.Schemas.ReauthenticationStatus? + private(set) var busy = false + private(set) var error: String? + private(set) var notice: String? + private(set) var retryAfter = Date.distantPast + private(set) var phoneToken: String? + private(set) var factorKind: Components.Schemas.FactorRequiredEnum? + private(set) var codeRequired = false + var method = Method.password + var password = "" + var code = "" + var confirmation = "" + private var factorToken: String? + private var proofExpires: Date? + private var active = true + private let repository: any AccountDeletionRepository + private let socialCredential: @MainActor (String, String) async throws -> ProviderCredential + private let onDeleted: @MainActor (Components.Schemas.AccountDeletionResult, Bool) async -> Void + private let now: () -> Date + + init(repository: any AccountDeletionRepository, + socialCredential: @escaping @MainActor (String, String) async throws -> ProviderCredential = { _, _ in throw NativeIdentity.unavailable }, + onDeleted: @escaping @MainActor (Components.Schemas.AccountDeletionResult, Bool) async -> Void, now: @escaping () -> Date = Date.init) { + self.repository = repository; self.socialCredential = socialCredential; self.onDeleted = onDeleted; self.now = now + } + + var blockers: [Components.Schemas.AccountDeletionBlocker] { preview?.blockers ?? [] } + /// Server-listed methods this app can complete natively. TOTP is the password's second factor. + var methods: [Method] { + (preview?.reauth.methods ?? []).compactMap { key in + switch key { + case "password": return .password + case "phone": return .phone + case "google", "apple": return .social(key) + default: return NativeOIDCClient.slug(fromKey: key) != nil ? .social(key) : nil + } + } + } + var authenticatorRequired: Bool { codeRequired || status?.mfa_enabled == true } + var proofValid: Bool { (proofExpires ?? .distantPast) > now() } + var canDelete: Bool { active && !busy && stage == .confirm && confirmation == "DELETE" && proofValid } + + func load() async { + guard active, !busy, now() >= retryAfter else { return } + busy = true; error = nil + defer { busy = false } + do { + let value = try await repository.preview() + let identity = try await repository.status() + guard active else { return } + preview = value; status = identity; notice = nil + if let first = methods.first, !methods.contains(method) { method = first } + if !value.blockers.isEmpty { stage = .blockers } + else if identity.verified, proofValid { stage = .confirm } + else { proofExpires = nil; stage = .verify } + } catch { fail(error) } + } + + func verifyPassword() async { + guard active, !busy, stage == .verify, method == .password else { return } + guard !password.isEmpty else { error = String(localized: "Enter your current password."); return } + let digits = code.filter { $0.isASCII && $0.isNumber } + if authenticatorRequired, digits.count != 6 { error = String(localized: "Enter the six-digit code from your authenticator app."); return } + await prove { try await self.repository.reauthenticate(password: self.password, code: self.authenticatorRequired ? digits : nil) } + } + + func sendPhoneCode() async { + guard active, !busy, stage == .verify, method == .phone, now() >= retryAfter else { return } + busy = true; error = nil + defer { busy = false } + do { + let started = try await repository.startPhone() + guard active else { return } + phoneToken = started.token; code = "" + notice = started.sent ? String(localized: "We sent a verification code to your phone.") : nil + } catch { fail(error) } + } + + func verifyPhone() async { + guard active, !busy, stage == .verify, method == .phone, let token = phoneToken else { return } + let digits = code.filter { $0.isASCII && $0.isNumber } + guard digits.count == 6 else { error = String(localized: "Enter the six-digit code."); return } + await prove { try await self.repository.confirmPhone(token: token, code: digits) } + } + + func verifySocial(_ provider: String) async { + guard active, !busy, stage == .verify, now() >= retryAfter else { return } + busy = true; error = nil + var started: Components.Schemas.ReauthenticationSocialStarted? + do { + started = try await repository.startSocial(provider: provider) + } catch { busy = false; fail(error); return } + guard let started, active else { busy = false; return } + let expires = now().addingTimeInterval(TimeInterval(started.expires_in)) + do { + let credential = try await socialCredential(provider, started.nonce) + guard now() < expires else { throw APIError(status: 0, code: "challenge_expired", detail: String(localized: "Verification expired. Try again.")) } + busy = false + await prove { try await self.repository.completeSocial(state: started.state, nonce: started.nonce, idToken: credential.idToken) } + } catch { busy = false; fail(error) } + } + + func verifyFactor() async { + guard active, !busy, stage == .factor, let token = factorToken else { return } + let digits = code.filter { $0.isASCII && $0.isNumber } + guard digits.count == 6 else { error = String(localized: "Enter the six-digit code."); return } + await prove { try await self.repository.factor(token: token, code: digits) } + } + + func delete() async { + guard active, !busy, stage == .confirm else { return } + guard proofValid else { requireProof(); return } + guard confirmation == "DELETE" else { error = String(localized: "Type DELETE to confirm."); return } + busy = true; error = nil + do { + let result = try await repository.delete() + guard active else { busy = false; return } + let restorable = preview?.restorable == true + clearSecrets(); proofExpires = nil; stage = .deleted; busy = false + await onDeleted(result, restorable) + } catch { + busy = false + guard active, !APIError.isCancellation(error) else { return } + let status = APIError.status(error) ?? 0, code = APIError.code(error) ?? "" + if status == 409 || (status == 503 && code == "deletion_retry") { + // The server rechecked participation; refresh the authoritative list before continuing. + confirmation = "" + await load() + notice = String(localized: "Something changed. Review the updated list before continuing.") + } else if status == 403 && code == "recent_reauth_required" { requireProof() } + else { fail(error) } + } + } + + func backToVerification() { guard active, !busy else { return }; requireProof(); error = nil } + func invalidate() { active = false; clearSecrets(); preview = nil; status = nil; proofExpires = nil; phoneToken = nil; factorToken = nil } + + private func prove(_ work: @escaping @MainActor () async throws -> Components.Schemas.ReauthenticationResult) async { + busy = true; error = nil + defer { busy = false } + do { + let result = try await work() + guard active else { return } + code = "" + if result.verified { + password = ""; factorToken = nil; factorKind = nil; codeRequired = false; notice = nil + proofExpires = now().addingTimeInterval(TimeInterval(max(0, result.expires_in))) + confirmation = ""; stage = .confirm + } else if let token = result.factor_token, let kind = result.factor_required { + password = ""; factorToken = token; factorKind = kind; stage = .factor + } else if result.mfa_required == true { + codeRequired = true; error = String(localized: "Enter the six-digit code from your authenticator app.") + } else { + error = String(localized: "Verify your identity to continue.") + } + } catch { fail(error) } + } + private func requireProof() { + proofExpires = nil; confirmation = ""; factorToken = nil; factorKind = nil; code = "" + stage = blockers.isEmpty ? .verify : .blockers + error = String(localized: "Verify your identity again before deleting.") + } + private func clearSecrets() { password = ""; code = ""; confirmation = "" } + private func fail(_ failure: Error) { + guard active, !APIError.isCancellation(failure) else { return } + error = APIError.display(failure) + if let deadline = APIError.retryDeadline(failure) { retryAfter = deadline } + } + + /// In-app fixes for blockers in a current membership. Organization and other fixes stay on the web. + static func destination(for blocker: Components.Schemas.AccountDeletionBlocker) -> MobileLink.Destination? { + switch blocker.code { + case "unpaid_charges", "refund_in_progress", "deposit_held": return .payments + case "active_loans", "open_requests": return .activity + case "upcoming_bookings": return .bookings + case "upcoming_registrations": return .events + default: return nil + } + } + /// Server-provided web fixes open only on the connected deployment's own HTTPS origin. + static func trustedWebFix(_ value: String?, origin: ServerOrigin) -> URL? { + guard let value, let url = URL(string: value), let parts = URLComponents(url: url, resolvingAgainstBaseURL: false), + parts.scheme == "https", parts.user == nil, parts.password == nil, + parts.host?.lowercased() == origin.url.host?.lowercased(), (parts.port ?? 443) == (origin.url.port ?? 443) else { return nil } + return url + } +} diff --git a/SpaceWorks/AccountDeletionView.swift b/SpaceWorks/AccountDeletionView.swift new file mode 100644 index 0000000..542719a --- /dev/null +++ b/SpaceWorks/AccountDeletionView.swift @@ -0,0 +1,139 @@ +import SwiftUI +import SpaceWorksAPI + +/// Whole-account deletion: server blockers with fixes, fresh identity proof, then a typed confirmation. +struct AccountDeletionView: View { + @State private var state: AccountDeletionState + let origin: ServerOrigin? + let openFix: ((MobileLink) -> Void)? + let memberships: Set + @State private var passwordFocused = false + @FocusState private var fieldFocused: Bool + @Environment(\.dismiss) private var dismiss + init(state: AccountDeletionState, origin: ServerOrigin?, memberships: Set, openFix: ((MobileLink) -> Void)?) { + _state = State(initialValue: state); self.origin = origin; self.memberships = memberships; self.openFix = openFix + } + var body: some View { + ScrollView { VStack(alignment: .leading, spacing: 24) { + Text("Delete account").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) + Text("This deletes your whole account on this server, across every makerspace.").brandFont(.headline) + Text("Your sessions, memberships and notifications end immediately. Payment, loan and audit history is kept anonymised under the retention policy. Leaving one makerspace is a separate action.") + .foregroundStyle(Color.swMuted) + if let notice = state.notice { Label(notice, systemImage: "info.circle").accessibilityIdentifier("deletion-notice") } + if let error = state.error { SWError(message: error) } + switch state.stage { + case .overview: + Button(state.busy ? "Checking…" : "Check account obligations") { Task { await state.load() } } + .buttonStyle(SWPrimary()).disabled(state.busy).accessibilityIdentifier("deletion-check") + case .blockers: blockers + case .verify: verification + case .factor: factor + case .confirm: confirmation + case .deleted: Label("Your account was deleted.", systemImage: "checkmark.circle").brandFont(.title2, weight: .bold) + } + }.padding(24).frame(maxWidth: 680).frame(maxWidth: .infinity) } + .background(Color.swBg).foregroundStyle(Color.swInk).tint(Color.swAccentInk) + .navigationTitle("Delete account").navigationBarTitleDisplayMode(.inline) + .accessibilityIdentifier("account-deletion") + .onDisappear { state.password = ""; state.code = ""; state.confirmation = "" } + .toolbar { ToolbarItemGroup(placement: .keyboard) { Spacer(); Button("Done") { fieldFocused = false; passwordFocused = false } } } + } + + @ViewBuilder private var blockers: some View { + Text("Resolve these first").brandFont(.title2, weight: .bold) + ForEach(Array(state.blockers.enumerated()), id: \.offset) { _, blocker in + SWCard { + if let slug = blocker.makerspace_slug { Text(verbatim: slug).brandFont(.headline) } + Text(blocker.detail) + Text("\(blocker.count) outstanding").brandFont(.subheadline).foregroundStyle(Color.swMuted) + if let id = blocker.makerspace_id, memberships.contains(id), let destination = AccountDeletionState.destination(for: blocker), let openFix { + Button("Open in the app") { openFix(MobileLink(makerspaceID: id, destination: destination)); dismiss() } + .frame(minHeight: 48).accessibilityIdentifier("deletion-fix-\(blocker.code)") + } + if let origin, let url = AccountDeletionState.trustedWebFix(blocker.organization_url ?? blocker.makerspace_member_url, origin: origin) { + Link("Open in the web console", destination: url).frame(minHeight: 48) + } + }.accessibilityElement(children: .contain) + } + Button(state.busy ? "Checking…" : "Check again") { Task { await state.load() } } + .buttonStyle(SWPrimary()).disabled(state.busy).accessibilityIdentifier("deletion-check") + } + + @ViewBuilder private var verification: some View { + Text("Confirm it's you").brandFont(.title2, weight: .bold) + Text("Verify your identity to continue. This proof lasts ten minutes.").foregroundStyle(Color.swMuted) + if state.methods.isEmpty { + Text("This account has no verification method available in the app. Use the web console to delete it.").foregroundStyle(Color.swMuted) + } else { + if state.methods.count > 1 { + Picker("Verification method", selection: $state.method) { + ForEach(state.methods) { method in Text(label(method)).tag(method) } + }.frame(minHeight: 48).onChange(of: state.method) { _, _ in state.password = ""; state.code = "" } + } + switch state.method { + case .password: + PasswordControl(title: String(localized: "Current password"), text: $state.password, identifier: "deletion-password", focus: $passwordFocused) + if state.authenticatorRequired { codeField(title: "Authenticator code") } + Button(state.busy ? "Verifying…" : "Verify identity") { Task { await state.verifyPassword() } } + .buttonStyle(SWPrimary()).disabled(state.busy).accessibilityIdentifier("deletion-verify") + case .phone: + if state.phoneToken == nil { + Button(state.busy ? "Sending…" : "Send verification code") { Task { await state.sendPhoneCode() } } + .buttonStyle(SWPrimary()).disabled(state.busy).accessibilityIdentifier("deletion-phone-send") + } else { + codeField(title: "Verification code") + Button(state.busy ? "Verifying…" : "Verify identity") { Task { await state.verifyPhone() } } + .buttonStyle(SWPrimary()).disabled(state.busy).accessibilityIdentifier("deletion-verify") + Button("Send another code") { Task { await state.sendPhoneCode() } }.frame(minHeight: 48).disabled(state.busy) + } + case .social(let provider): + Button(state.busy ? "Verifying…" : "Continue with \(label(.social(provider)))") { Task { await state.verifySocial(provider) } } + .buttonStyle(SWPrimary()).disabled(state.busy).accessibilityIdentifier("deletion-social") + } + } + } + + @ViewBuilder private var factor: some View { + Text("One more step").brandFont(.title2, weight: .bold) + Text(state.factorKind == .totp ? "Enter the six-digit code from your authenticator app." : "Enter the six-digit code we sent you.").foregroundStyle(Color.swMuted) + codeField(title: "Verification code") + Button(state.busy ? "Verifying…" : "Verify identity") { Task { await state.verifyFactor() } } + .buttonStyle(SWPrimary()).disabled(state.busy).accessibilityIdentifier("deletion-factor") + } + + @ViewBuilder private var confirmation: some View { + Text("Confirm deletion").brandFont(.title2, weight: .bold) + if state.preview?.restorable == true { + Text("You can ask to restore your identity before the retention period ends. Memberships, roles, sessions and notifications stay revoked.").foregroundStyle(Color.swMuted) + } else { + Text("This account cannot be restored after deletion.").foregroundStyle(Color.swMuted) + } + if let reason = state.preview?.restore_reason, !reason.isEmpty { Text(reason).foregroundStyle(Color.swMuted) } + Text("Type DELETE to confirm").brandFont(.headline) + TextField("Type DELETE", text: $state.confirmation).textInputAutocapitalization(.characters).autocorrectionDisabled().focused($fieldFocused) + .padding(16).background(Color.swPanel, in: RoundedRectangle(cornerRadius: 10)).overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)) + .accessibilityIdentifier("deletion-confirmation") + Button(state.busy ? "Deleting…" : "Delete account", role: .destructive) { Task { await state.delete() } } + .buttonStyle(SWPrimary()).disabled(!state.canDelete).accessibilityIdentifier("deletion-submit") + Button("Verify again") { state.backToVerification() }.frame(minHeight: 48).disabled(state.busy) + } + + private func codeField(title: LocalizedStringKey) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title).brandFont(.headline) + TextField(title, text: $state.code).textContentType(.oneTimeCode).keyboardType(.numberPad).focused($fieldFocused) + .padding(16).background(Color.swPanel, in: RoundedRectangle(cornerRadius: 10)).overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)) + .accessibilityIdentifier("deletion-code") + } + } + + private func label(_ method: AccountDeletionState.Method) -> String { + switch method { + case .password: return String(localized: "Password") + case .phone: return String(localized: "Phone code") + case .social("google"): return "Google" + case .social("apple"): return "Apple" + case .social(let key): return NativeOIDCClient.slug(fromKey: key) ?? key + } + } +} diff --git a/SpaceWorks/AccountState.swift b/SpaceWorks/AccountState.swift index c8bb07c..6e9bfe3 100644 --- a/SpaceWorks/AccountState.swift +++ b/SpaceWorks/AccountState.swift @@ -13,6 +13,11 @@ final class AccountState { private(set) var exportEnabled: Bool let makerspaceName: String let currentDeviceGrantID: String? + /// Whole-account deletion is global; the authenticated owner supplies it and in-app blocker fixes. + @ObservationIgnored var makeDeletion: (@MainActor () -> AccountDeletionState?)? + @ObservationIgnored var deletionOrigin: ServerOrigin? + @ObservationIgnored var deletionMemberships: Set = [] + @ObservationIgnored var openDeletionFix: ((MobileLink) -> Void)? private let beginSecurityEnrollment: (Components.Schemas.MFAChallengeResponse) async -> Void private let endSession: ([String], String?) async -> Void private let membershipEnded: () async -> Void diff --git a/SpaceWorks/AccountViews.swift b/SpaceWorks/AccountViews.swift index 10aae87..5e796f5 100644 --- a/SpaceWorks/AccountViews.swift +++ b/SpaceWorks/AccountViews.swift @@ -43,17 +43,29 @@ struct AccountView: View { NavigationLink("Preview account deletion") { DemoDeletionView(onFix: onDemoFix.map { route in { blocker in route(blocker); dismiss() } }, onDelete: onDemoDelete) }.frame(minHeight: 48).accessibilityIdentifier("account-delete-preview") - } else { - Text("Account deletion isn't available on this server yet. Contact your makerspace for help.").foregroundStyle(Color.swMuted) - } + } else { liveDeletion } #else - Text("Account deletion isn't available on this server yet. Contact your makerspace for help.").foregroundStyle(Color.swMuted) + liveDeletion #endif } }.padding(24).frame(maxWidth: 900).frame(maxWidth: .infinity) }.background(Color.swBg).foregroundStyle(Color.swInk).tint(Color.swAccentInk).navigationTitle("Account and security").navigationBarTitleDisplayMode(.inline) } } +extension AccountView { + @ViewBuilder var liveDeletion: some View { + if let make = state.makeDeletion, let deletion = make() { + NavigationLink { + AccountDeletionView(state: deletion, origin: state.deletionOrigin, memberships: state.deletionMemberships, openFix: state.openDeletionFix) + } label: { + AccountMenuRow(title: "Delete account", detail: String(localized: "Delete your whole account on this server"), icon: "trash") + }.accessibilityIdentifier("account-delete") + } else { + Text("Sign in again to delete your account.").foregroundStyle(Color.swMuted) + } + } +} + enum AccountLinks { static func safe(_ value: URL?) -> URL? { guard let value, value.scheme == "https", value.host?.isEmpty == false, value.user == nil, value.password == nil else { return nil }; return value diff --git a/SpaceWorks/AuthState.swift b/SpaceWorks/AuthState.swift index 3f736cf..038686d 100644 --- a/SpaceWorks/AuthState.swift +++ b/SpaceWorks/AuthState.swift @@ -370,14 +370,7 @@ final class AuthState { } busy = false guard identityGeneration == generation, user?.id == accountID else { return } - // The server already removed this device's grant and push destinations. - endingSession = true - detachNotifications() - clearLocalIdentity() - try? await service.refresh.install(nil) - try? await service.makerspace.select(nil, activeMemberships: []) - endingSession = false - error = String(localized: "Signed out on every device and browser.") + await endRevokedSession(message: String(localized: "Signed out on every device and browser.")) } func changePassword(current: String, new: String, confirmation: String) async { @@ -409,6 +402,7 @@ final class AuthState { makerspaceID == nil || memberships.contains(where: { $0.id == makerspaceID }) else { return nil } let generation = identityGeneration let name = memberships.first(where: { $0.id == makerspaceID })?.slug ?? "" + let deletionMemberships = Set(memberships.map(\.id)) let repository = LiveAccountRepository(service: service, expectedCredentials: credentials, makerspaceID: makerspaceID, origin: origin, onUnauthorized: { [weak self] message in await self?.accountRefused(message, accountID: accountID, generation: generation) }, onForbidden: { [weak self] in await self?.accountAuthorityChanged(accountID: accountID, generation: generation) }) @@ -438,8 +432,56 @@ final class AuthState { if let ids = try? await self.freshMembershipIDs(), ids.contains(makerspaceID), self.user?.id == accountID { await self.notifications?.registration.resumeMembership(makerspaceID, authorizedDirectMembershipIDs: ids) } - }) + }).withDeletion(origin: origin, memberships: deletionMemberships) { [weak self] in + guard let self, self.identityGeneration == generation, self.user?.id == accountID else { return nil } + return self.accountDeletion() + } + } + /// Whole-account deletion for the current device session. Social proof uses the published native providers. + func accountDeletion() -> AccountDeletionState? { + guard let accountID = user?.id, let credentials = acceptedCredentials, stage == .signedIn else { return nil } + let generation = identityGeneration + let repository = LiveAccountDeletionRepository(service: service, expectedCredentials: credentials, + onUnauthorized: { [weak self] message in await self?.accountRefused(message, accountID: accountID, generation: generation) }) + return AccountDeletionState(repository: repository, socialCredential: { [weak self] provider, nonce in + guard let self else { throw CancellationError() } + return try await self.reauthenticationCredential(provider: provider, nonce: nonce) + }, onDeleted: { [weak self] result, restorable in + guard let self, self.identityGeneration == generation, self.user?.id == accountID else { return } + let message = restorable + ? String(format: String(localized: "Your account was deleted. You can ask to restore it before %@ using password recovery."), + result.purge_after.formatted(date: .abbreviated, time: .omitted)) + : String(localized: "Your account was deleted.") + await self.endRevokedSession(message: message) + }) } + + private func reauthenticationCredential(provider: String, nonce: String) async throws -> ProviderCredential { + switch provider { + case "google": + guard let clientID = googleClientID, let controller = NativePresentation.topController else { throw NativeIdentity.unavailable } + return try await NativeIdentity.google(clientID: clientID, nonce: nonce, presenting: controller) + case "apple": + guard appleEnabled, let window = NativePresentation.window else { throw NativeIdentity.unavailable } + let apple = AppleIdentity() + return try await apple.credential(nonce: nonce, anchor: window) + default: + guard let organization = organizationProviders.first(where: { $0.key == provider }), let window = NativePresentation.window else { throw NativeIdentity.unavailable } + return try await NativeOIDCSignIn().credential(issuer: organization.issuer, clientID: organization.client_id, nonce: nonce, anchor: window) + } + } + + /// The server already revoked this device's grant and push destinations; clear local state only. + private func endRevokedSession(message: String) async { + endingSession = true + detachNotifications() + clearLocalIdentity() + try? await service.refresh.install(nil) + try? await service.makerspace.select(nil, activeMemberships: []) + endingSession = false + error = message + } + private func accountRefused(_ message: String, accountID: Int, generation: Int) async { guard identityGeneration == generation, user?.id == accountID else { return } await invalidateSession(message: message) diff --git a/SpaceWorks/RootView.swift b/SpaceWorks/RootView.swift index 9920f04..fa89839 100644 --- a/SpaceWorks/RootView.swift +++ b/SpaceWorks/RootView.swift @@ -207,7 +207,10 @@ private struct MemberWorkspace: View { let actions = LiveMemberActionsRepository(service: auth.service, expectedCredentials: auth.acceptedCredentials, id: id, slug: slug, onUnauthorized: failureHandlers.unauthorized, onForbidden: failureHandlers.forbidden) self.actions = actions - _account = State(initialValue: auth.accountState(makerspaceID: id)) + let account = auth.accountState(makerspaceID: id) + // Deletion blockers in a current membership open the matching in-app task through validated routing. + account?.openDeletionFix = { [linkRouter] link in linkRouter.receive(link) } + _account = State(initialValue: account) // Native staff authority plus at least one staff action of this membership; the server still enforces each call. let membershipActions = Set(auth.selectedMembership?.actions ?? []) _staff = State(initialValue: auth.staffToolsAvailable && auth.selectedMembership?.id == id && !membershipActions.isDisjoint(with: StaffState.toolActions) diff --git a/SpaceWorksTests/AccountDeletionTests.swift b/SpaceWorksTests/AccountDeletionTests.swift new file mode 100644 index 0000000..373398e --- /dev/null +++ b/SpaceWorksTests/AccountDeletionTests.swift @@ -0,0 +1,183 @@ +import XCTest +import Foundation +import SpaceWorksAPI +@testable import SpaceWorks + +private actor DeletionFake: AccountDeletionRepository { + var blockers: [String] = [] + var methods = ["password", "totp", "phone", "google", "github", "oidc:campus"] + var verified = false + var mfa = true + var deleteFailure: APIError? + var reauthCalls: [(String, String?)] = [] + var deleted = 0 + func configure(blockers: [String]) { self.blockers = blockers } + func configure(deleteFailure: APIError?) { self.deleteFailure = deleteFailure } + func configure(verified: Bool) { self.verified = verified } + private func decode(_ json: String) throws -> T { + let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(T.self, from: Data(json.utf8)) + } + private func blocker(_ code: String) -> String { + #"{"code":"\#(code)","makerspace_id":7,"makerspace_slug":"fab-lab","makerspace_member_url":null,"organization_url":null,"organization_id":null,"count":2,"detail":"Server detail for \#(code)."}"# + } + func preview() async throws -> Components.Schemas.AccountDeletionPreview { + let rows = blockers.map(blocker).joined(separator: ",") + let names = methods.map { "\"\($0)\"" }.joined(separator: ",") + return try decode(#"{"deletable":\#(blockers.isEmpty),"restorable":true,"restore_reason":"","blockers":[\#(rows)],"reauth":{"required":true,"methods":[\#(names)]}}"#) + } + func status() async throws -> Components.Schemas.ReauthenticationStatus { + try decode(#"{"verified":\#(verified),"password_available":true,"mfa_enabled":\#(mfa),"providers":["google"]}"#) + } + func reauthenticate(password: String, code: String?) async throws -> Components.Schemas.ReauthenticationResult { + reauthCalls.append((password, code)) + guard password == "correct-password", code == "123456" else { + throw APIError(status: 0, code: "identity_verification_failed", detail: "Identity verification failed.") + } + return try decode(#"{"verified":true,"expires_in":600}"#) + } + func startPhone() async throws -> Components.Schemas.ReauthenticationPhoneStarted { try decode(#"{"sent":true,"token":"phone-token"}"#) } + func confirmPhone(token: String, code: String) async throws -> Components.Schemas.ReauthenticationResult { + XCTAssertEqual(token, "phone-token"); return try decode(#"{"verified":true,"expires_in":600}"#) + } + func startSocial(provider: String) async throws -> Components.Schemas.ReauthenticationSocialStarted { + try decode(#"{"authorization_url":"https://accounts.example/auth","state":"server-state","nonce":"server-nonce","expires_in":300}"#) + } + func completeSocial(state: String, nonce: String, idToken: String) async throws -> Components.Schemas.ReauthenticationResult { + XCTAssertEqual(state, "server-state"); XCTAssertEqual(nonce, "server-nonce"); XCTAssertEqual(idToken, "provider-token") + return try decode(#"{"verified":false,"expires_in":0,"mfa_required":true,"factor_required":"totp","factor_token":"factor-token"}"#) + } + func factor(token: String, code: String) async throws -> Components.Schemas.ReauthenticationResult { + XCTAssertEqual(token, "factor-token"); return try decode(#"{"verified":true,"expires_in":600}"#) + } + func delete() async throws -> Components.Schemas.AccountDeletionResult { + if let deleteFailure { self.deleteFailure = nil; throw deleteFailure } + deleted += 1 + return try decode(#"{"deleted_at":"2026-10-18T10:00:00Z","purge_after":"2027-01-16T10:00:00Z"}"#) + } +} + +@MainActor final class AccountDeletionTests: XCTestCase { + func testBlockersAreShownWithServerDetailAndInAppFixDestinations() async { + let fake = DeletionFake(); await fake.configure(blockers: ["unpaid_charges", "active_loans", "last_manager", "future_code"]) + let state = AccountDeletionState(repository: fake, onDeleted: { _, _ in XCTFail("Blocked accounts are never deleted") }) + await state.load() + XCTAssertEqual(state.stage, .blockers) + XCTAssertEqual(state.blockers.map(\.detail).last, "Server detail for future_code.", "Unknown codes keep their server detail") + XCTAssertEqual(state.blockers.map { AccountDeletionState.destination(for: $0) }, [.payments, .activity, nil, nil]) + } + + func testOnlyNativelyCompletableMethodsAreOffered() async { + let state = AccountDeletionState(repository: DeletionFake(), onDeleted: { _, _ in }) + await state.load() + XCTAssertEqual(state.methods, [.password, .phone, .social("google"), .social("oidc:campus")], "TOTP pairs with the password and GitHub is web-only") + XCTAssertTrue(state.authenticatorRequired) + } + + func testPasswordProofThenTypedConfirmationDeletesOnce() async { + let fake = DeletionFake() + var deletedRestorable: Bool? + let state = AccountDeletionState(repository: fake, onDeleted: { _, restorable in deletedRestorable = restorable }) + await state.load(); XCTAssertEqual(state.stage, .verify) + state.password = "wrong"; state.code = "123456"; await state.verifyPassword() + XCTAssertEqual(state.stage, .verify); XCTAssertEqual(state.error, "Identity verification failed.") + state.password = "correct-password"; state.code = "12"; await state.verifyPassword() + XCTAssertEqual(state.error, "Enter the six-digit code from your authenticator app.") + state.code = "123456"; await state.verifyPassword() + XCTAssertEqual(state.stage, .confirm); XCTAssertEqual(state.password, "", "Secrets are cleared after proof") + state.confirmation = "delete"; XCTAssertFalse(state.canDelete) + state.confirmation = "DELETE"; XCTAssertTrue(state.canDelete) + await state.delete() + XCTAssertEqual(state.stage, .deleted); XCTAssertEqual(deletedRestorable, true) + let deleted = await fake.deleted + XCTAssertEqual(deleted, 1) + } + + func testExpiredProofReturnsToVerificationWithoutDeleting() async { + var clock = Date(timeIntervalSince1970: 1_000) + let fake = DeletionFake() + let state = AccountDeletionState(repository: fake, onDeleted: { _, _ in XCTFail("An expired proof must not delete") }, now: { clock }) + await state.load() + state.password = "correct-password"; state.code = "123456"; await state.verifyPassword() + XCTAssertEqual(state.stage, .confirm) + clock = clock.addingTimeInterval(601) + state.confirmation = "DELETE"; await state.delete() + XCTAssertEqual(state.stage, .verify); XCTAssertEqual(state.error, "Verify your identity again before deleting.") + let deleted = await fake.deleted + XCTAssertEqual(deleted, 0) + } + + func testConflictAtConfirmationRefetchesTheAuthoritativeBlockers() async { + let fake = DeletionFake() + let state = AccountDeletionState(repository: fake, onDeleted: { _, _ in XCTFail("A conflict must not delete") }) + await state.load(); state.password = "correct-password"; state.code = "123456"; await state.verifyPassword() + await fake.configure(blockers: ["upcoming_bookings"]) + await fake.configure(deleteFailure: APIError(status: 409, code: "http_409", detail: "This changed. Refresh and try again.")) + state.confirmation = "DELETE"; await state.delete() + XCTAssertEqual(state.stage, .blockers); XCTAssertEqual(state.blockers.first?.code, "upcoming_bookings") + XCTAssertEqual(state.notice, "Something changed. Review the updated list before continuing.") + } + + func testSocialProofCanRequireASecondFactor() async { + let state = AccountDeletionState(repository: DeletionFake(), socialCredential: { provider, nonce in + XCTAssertEqual(provider, "google"); XCTAssertEqual(nonce, "server-nonce") + return ProviderCredential(idToken: "provider-token") + }, onDeleted: { _, _ in }) + await state.load() + await state.verifySocial("google") + XCTAssertEqual(state.stage, .factor); XCTAssertEqual(state.factorKind, .totp) + state.code = "654321"; await state.verifyFactor() + XCTAssertEqual(state.stage, .confirm) + } + + func testPhoneOnlyProofUsesTheSeparateReauthenticationCode() async { + let state = AccountDeletionState(repository: DeletionFake(), onDeleted: { _, _ in }) + await state.load(); state.method = .phone + await state.sendPhoneCode() + XCTAssertEqual(state.phoneToken, "phone-token"); XCTAssertEqual(state.notice, "We sent a verification code to your phone.") + state.code = "111222"; await state.verifyPhone() + XCTAssertEqual(state.stage, .confirm) + } + + func testWebFixesOpenOnlyOnTheConnectedDeploymentOrigin() throws { + let origin = try ServerOrigin("https://spaceworks.example") + XCTAssertNotNil(AccountDeletionState.trustedWebFix("https://spaceworks.example/organizations/4", origin: origin)) + XCTAssertNil(AccountDeletionState.trustedWebFix("https://evil.example/organizations/4", origin: origin)) + XCTAssertNil(AccountDeletionState.trustedWebFix("http://spaceworks.example/organizations/4", origin: origin)) + XCTAssertNil(AccountDeletionState.trustedWebFix(nil, origin: origin)) + } + + func testGeneratedDeletionOperationsUseTheDocumentedGlobalRequests() async throws { + var seen: [String] = [] + StubProtocol.lock.lock() + StubProtocol.handler = { request in + seen.append("\(request.httpMethod ?? "") \(request.url!.path)") + XCTAssertNil(request.value(forHTTPHeaderField: "X-Makerspace-Id")) + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer fixture-access") + let json = ["Content-Type": "application/json"] + switch (request.httpMethod ?? "", request.url!.path) { + case ("GET", "/api/v1/auth/account/deletion"): + return (200, json, Data(#"{"deletable":true,"restorable":false,"restore_reason":"","blockers":[],"reauth":{"required":true,"methods":["password"]}}"#.utf8)) + case ("POST", "/api/v1/auth/reauth"): + let body = try JSONSerialization.jsonObject(with: requestBody(request)) as? [String: String] + XCTAssertEqual(body, ["password": "correct-password", "code": "123456"]) + return (200, json, Data(#"{"verified":true,"expires_in":600}"#.utf8)) + case ("POST", "/api/v1/auth/account/deletion"): + let body = try JSONSerialization.jsonObject(with: requestBody(request)) as? [String: String] + XCTAssertEqual(body, ["confirm": "DELETE"]) + return (202, json, Data(#"{"deleted_at":"2026-10-18T10:00:00Z","purge_after":"2027-01-16T10:00:00Z"}"#.utf8)) + default: XCTFail("Unexpected request \(request.url!)"); return (500, json, Data("{}".utf8)) + } + } + StubProtocol.lock.unlock() + let configuration = DeviceSession.configuration(); configuration.protocolClasses = [StubProtocol.self] + let service = SpaceWorksService(origin: try ServerOrigin("https://spaceworks.example"), session: DeviceSession.make(configuration: configuration), + initialTokens: TokenPair(access: "fixture-access", refresh: "fixture-refresh")) + let credentials = await service.refresh.snapshot() + let repository = LiveAccountDeletionRepository(service: service, expectedCredentials: credentials) + let preview = try await repository.preview(); XCTAssertTrue(preview.deletable) + let proof = try await repository.reauthenticate(password: "correct-password", code: "123456"); XCTAssertTrue(proof.verified) + let result = try await repository.delete(); XCTAssertGreaterThan(result.purge_after, result.deleted_at) + XCTAssertEqual(seen, ["GET /api/v1/auth/account/deletion", "POST /api/v1/auth/reauth", "POST /api/v1/auth/account/deletion"]) + } +} From ba9fbb3e6c410e8ecc2e88a58a36a094d4dac291 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:48:46 +0530 Subject: [PATCH 87/98] feat(d5): route v2 push and universal links through the server link contract Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- SpaceWorks/AuthView.swift | 2 +- SpaceWorks/DemoMode.swift | 2 +- SpaceWorks/MobileLink.swift | 43 +++++++++++- SpaceWorks/MobileLinkRouter.swift | 5 +- SpaceWorks/Platform/NativeNotifications.swift | 4 +- SpaceWorks/RootView.swift | 1 + SpaceWorksTests/MobileLinkContractTests.swift | 70 +++++++++++++++++++ 7 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 SpaceWorksTests/MobileLinkContractTests.swift diff --git a/SpaceWorks/AuthView.swift b/SpaceWorks/AuthView.swift index b5720b2..df3d6b3 100644 --- a/SpaceWorks/AuthView.swift +++ b/SpaceWorks/AuthView.swift @@ -51,7 +51,7 @@ struct AuthRoot: View { guard let membership = auth.memberships.first(where: { $0.slug == legacy.slug }), !legacy.staffInbox || auth.staffToolsAvailable else { throw APIError(status: 403, code: "link_membership_unavailable", detail: String(localized: "This update isn't available to your account.")) } - return MobileLink(makerspaceID: membership.id, destination: legacy.staffInbox ? .inbox : .home) + return legacy.link(makerspaceID: membership.id) }, owner: routingOwner) Task { await linkRouter.process() } } else { linkRouter.unbind(owner: routingOwner) } diff --git a/SpaceWorks/DemoMode.swift b/SpaceWorks/DemoMode.swift index e900a36..a845420 100644 --- a/SpaceWorks/DemoMode.swift +++ b/SpaceWorks/DemoMode.swift @@ -138,7 +138,7 @@ struct DemoRoot: View { }, resolveLegacy: { legacy in guard session.stage == .shell, !legacy.staffInbox || session.role == .staff, let id = ["fab-lab-kochi": 7, "maker-space-bengaluru": 8][legacy.slug], session.availableSpaces.contains(id - 7) else { throw CancellationError() } - return MobileLink(makerspaceID: id, destination: legacy.staffInbox ? .inbox : .home) + return legacy.link(makerspaceID: id) }) Task { await linkRouter.process() } } diff --git a/SpaceWorks/MobileLink.swift b/SpaceWorks/MobileLink.swift index d2b3119..a1df6bf 100644 --- a/SpaceWorks/MobileLink.swift +++ b/SpaceWorks/MobileLink.swift @@ -12,11 +12,18 @@ struct MobileLink: Equatable, Sendable { guard hint.version == 2, let relative = hint.relativeLink, let link = path(relative), link.makerspaceID == hint.makerspaceID else { return .init(makerspaceID: hint.makerspaceID, destination: .inbox) } return link } + /// A shared-host web path (`/m/{slug}/…`) from the server's mobile link contract. The slug is resolved + /// against a fresh membership list before any navigation. struct Legacy: Equatable { let slug: String let staffInbox: Bool let expectedMakerspaceID: Int? - init(slug: String, staffInbox: Bool, expectedMakerspaceID: Int? = nil) { self.slug = slug; self.staffInbox = staffInbox; self.expectedMakerspaceID = expectedMakerspaceID } + let destination: Destination + init(slug: String, staffInbox: Bool, expectedMakerspaceID: Int? = nil, destination: Destination = .home) { + self.slug = slug; self.staffInbox = staffInbox; self.expectedMakerspaceID = expectedMakerspaceID; self.destination = destination + } + /// The membership-scoped intent once the slug has resolved to an authorized makerspace. + func link(makerspaceID: Int) -> MobileLink { MobileLink(makerspaceID: makerspaceID, destination: staffInbox ? .inbox : destination) } } static func legacy(_ raw: String, origin: ServerOrigin? = nil) -> Legacy? { let path: String @@ -33,9 +40,39 @@ struct MobileLink: Equatable, Sendable { let pieces = path.split(separator: "/").map(String.init) guard pieces.count >= 3, pieces[0] == "m", pieces[1].count <= 200, pieces[1].range(of: "^[A-Za-z0-9_-]+$", options: .regularExpression) != nil else { return nil } - if pieces.count == 3, pieces[2] == "member" { return Legacy(slug: pieces[1], staffInbox: false) } if pieces.count == 4, pieces[2] == "admin", pieces[3] == "notifications" { return Legacy(slug: pieces[1], staffInbox: true) } - return nil + guard let destination = contract(Array(pieces.dropFirst(2))) else { return nil } + return Legacy(slug: pieces[1], staffInbox: false, destination: destination) + } + /// The custom-domain form of the contract: the verified host names the makerspace, so the path has no + /// `/m/{slug}` prefix. Only usable where the makerspace is already known (a push payload or its inbox). + static func customDomain(_ path: String) -> Destination? { + guard path.utf8.count <= 2048, path.hasPrefix("/"), !path.contains("//"), !path.contains("%"), !path.contains("\\"), + !path.contains("?"), !path.contains("#"), path.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) else { return nil } + return contract(path.split(separator: "/").map(String.init)) + } + /// DESTINATIONS from the backend's mobile_link_contract.py, after any `/m/{slug}` prefix. + /// Staff admin, control, API and other web pages are never claimed. + private static func contract(_ pieces: [String]) -> Destination? { + guard !pieces.contains("."), !pieces.contains("..") else { return nil } + switch pieces.count { + case 1 where pieces[0] == "member": return .home + case 2 where pieces[0] == "member": + switch pieces[1] { + case "account": return .profile + case "loans": return .activity + case "bookings": return .bookings + case "events": return .events + case "payments": return .payments + case "inbox": return .inbox + default: return .home + } + case 2 where pieces[0] == "events": return uuid(pieces[1]).map { .event($0) } + case 2 where pieces[0] == "bookings": return uuid(pieces[1]).map { .booking($0) } + case 2 where pieces[0] == "items": return positive(pieces[1]).map { .product($0) } + case 4 where pieces[0] == "member" && pieces[1] == "events" && pieces[3] == "ticket": return positive(pieces[2]).map { .registrations($0) } + default: return nil + } } static func inbox(_ value: String, origin: ServerOrigin) -> MobileLink? { if value.hasPrefix("/") { return path(value) } diff --git a/SpaceWorks/MobileLinkRouter.swift b/SpaceWorks/MobileLinkRouter.swift index 02f2c15..de1a45f 100644 --- a/SpaceWorks/MobileLinkRouter.swift +++ b/SpaceWorks/MobileLinkRouter.swift @@ -30,7 +30,10 @@ import SpaceWorksAPI func refuseURL() { cancelIntent(); error = String(localized: "That link can't be opened in this app.") } func receive(_ hint: NotificationHint) { if hint.version == 2, let path = hint.relativeLink, let legacy = MobileLink.legacy(path) { - receive(MobileLink.Legacy(slug: legacy.slug, staffInbox: legacy.staffInbox, expectedMakerspaceID: hint.makerspaceID)) + receive(MobileLink.Legacy(slug: legacy.slug, staffInbox: legacy.staffInbox, expectedMakerspaceID: hint.makerspaceID, destination: legacy.destination)) + } else if hint.version == 2, let path = hint.relativeLink, let destination = MobileLink.customDomain(path) { + // The payload's makerspace owns a custom-domain path; fresh membership still gates it. + receive(MobileLink(makerspaceID: hint.makerspaceID, destination: destination)) } else { receive(MobileLink.notification(hint)) } pendingNeedsInbox = true } diff --git a/SpaceWorks/Platform/NativeNotifications.swift b/SpaceWorks/Platform/NativeNotifications.swift index 3c35b63..c24d168 100644 --- a/SpaceWorks/Platform/NativeNotifications.swift +++ b/SpaceWorks/Platform/NativeNotifications.swift @@ -12,7 +12,9 @@ struct NotificationHint: Equatable { let inboxRowID: Int? let relativeLink: String? - init?(payload: [AnyHashable: Any]) { + /// APNs delivers the string-valued routing keys under `data` beside `aps`; older payloads were flat. + init?(payload notification: [AnyHashable: Any]) { + let payload = (notification["data"] as? [AnyHashable: Any]) ?? notification guard let id = Self.positiveID(payload["makerspace_id"]) else { return nil } makerspaceID = id version = Int(Self.string(payload["v"]) ?? "1") ?? 0 diff --git a/SpaceWorks/RootView.swift b/SpaceWorks/RootView.swift index fa89839..8db7f9e 100644 --- a/SpaceWorks/RootView.swift +++ b/SpaceWorks/RootView.swift @@ -324,6 +324,7 @@ private struct MemberScreen: View { guard let origin = state.connectedOrigin else { linkRouter.refuseURL(); return } if let link = MobileLink.inbox(text, origin: origin) { linkRouter.receive(link) } else if let legacy = MobileLink.legacy(text, origin: origin) { linkRouter.receive(legacy) } + else if let destination = MobileLink.customDomain(text), let id = auth.selectedID { linkRouter.receive(MobileLink(makerspaceID: id, destination: destination)) } else { linkRouter.refuseURL() } }) } else { ScrollView { diff --git a/SpaceWorksTests/MobileLinkContractTests.swift b/SpaceWorksTests/MobileLinkContractTests.swift new file mode 100644 index 0000000..9852844 --- /dev/null +++ b/SpaceWorksTests/MobileLinkContractTests.swift @@ -0,0 +1,70 @@ +import XCTest +import SpaceWorksAPI +@testable import SpaceWorks + +/// Mirrors backend/apps/integrations/mobile_link_contract.py DESTINATIONS and EXCLUDED_PREFIXES. +@MainActor final class MobileLinkContractTests: XCTestCase { + private let token = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" + + func testSharedHostContractDestinationsResolveThroughTheirSlug() throws { + let origin = try ServerOrigin("https://spaceworks.example") + let cases: [(String, MobileLink.Destination)] = [ + ("/m/fab-lab/member", .home), ("/m/fab-lab/member/", .home), ("/m/fab-lab/member/overview", .home), + ("/m/fab-lab/member/loans", .activity), ("/m/fab-lab/member/bookings", .bookings), ("/m/fab-lab/member/events", .events), + ("/m/fab-lab/member/payments", .payments), ("/m/fab-lab/member/inbox", .inbox), ("/m/fab-lab/member/account", .profile), + ("/m/fab-lab/member/events/41/ticket", .registrations(41)), ("/m/fab-lab/events/\(token)", .event(token)), + ("/m/fab-lab/bookings/\(token)", .booking(token)), ("/m/fab-lab/items/12", .product(12)), + ("https://spaceworks.example/m/fab-lab/items/12", .product(12))] + for (path, destination) in cases { + let legacy = try XCTUnwrap(MobileLink.legacy(path, origin: origin), path) + XCTAssertEqual(legacy.slug, "fab-lab", path); XCTAssertFalse(legacy.staffInbox, path) + XCTAssertEqual(legacy.link(makerspaceID: 7), MobileLink(makerspaceID: 7, destination: destination), path) + } + } + + func testExcludedAndMalformedPathsAreNeverClaimed() throws { + let origin = try ServerOrigin("https://spaceworks.example") + for path in ["/m/fab-lab/admin", "/m/fab-lab/admin/requests", "/control", "/api/v1/auth/me", "/m/fab-lab/events/not-a-token", + "/m/fab-lab/items/0", "/m/fab-lab/member/events/x/ticket", "/m/fab-lab/member/a/b", "/m/fab-lab/items/12?x=1", + "https://other.example/m/fab-lab/member", "/m/fab lab/member"] { + XCTAssertNil(MobileLink.legacy(path, origin: origin), path) + } + XCTAssertEqual(MobileLink.legacy("/m/fab-lab/admin/notifications", origin: origin)?.staffInbox, true, "The legacy staff inbox link is unchanged") + } + + func testCustomDomainFormsNeedAKnownMakerspace() { + XCTAssertEqual(MobileLink.customDomain("/member"), .home) + XCTAssertEqual(MobileLink.customDomain("/member/account"), .profile) + XCTAssertEqual(MobileLink.customDomain("/events/\(token)"), .event(token)) + XCTAssertEqual(MobileLink.customDomain("/items/9"), .product(9)) + XCTAssertNil(MobileLink.customDomain("/admin")) + XCTAssertNil(MobileLink.customDomain("/m/fab-lab/member"), "A shared-host path is not a custom-domain path") + } + + func testAPNsDataDictionaryCarriesTheVersionTwoRoutingKeys() throws { + let payload: [AnyHashable: Any] = ["aps": ["alert": ["title": "Fab Lab", "body": "Your loan is due"], "thread-id": "7"], + "data": ["v": "2", "event_id": "evt-1", "makerspace_id": "7", "feature": "loans", + "event": "loan.due", "inbox_row_id": "55", "link": "/m/fab-lab/member/loans", "unknown": "kept out"]] + let hint = try XCTUnwrap(NotificationHint(payload: payload)) + XCTAssertEqual(hint.makerspaceID, 7); XCTAssertEqual(hint.version, 2); XCTAssertEqual(hint.eventID, "evt-1") + XCTAssertEqual(hint.inboxRowID, 55); XCTAssertEqual(hint.relativeLink, "/m/fab-lab/member/loans") + let flat = try XCTUnwrap(NotificationHint(payload: ["makerspace_id": "8"])) + XCTAssertEqual(flat.version, 1, "A missing version is v1"); XCTAssertNil(flat.relativeLink) + } + + func testPushContractLinkKeepsItsDestinationAndExplicitMakerspace() async throws { + let router = MobileLinkRouter() + router.bind(account: "account", refresh: { [7, 8] }, select: { XCTAssertEqual($0, 7) }, resolveLegacy: { legacy in + XCTAssertEqual(legacy.slug, "fab-lab"); return legacy.link(makerspaceID: 7) + }) + router.receive(try XCTUnwrap(NotificationHint(payload: ["data": ["v": "2", "makerspace_id": "7", "link": "/m/fab-lab/member/loans"]]))) + await router.process() + XCTAssertEqual(router.approved?.link, MobileLink(makerspaceID: 7, destination: .activity)) + XCTAssertEqual(router.approved?.refetchInbox, true) + let custom = MobileLinkRouter() + custom.bind(account: "account", refresh: { [7] }, select: { _ in }) + custom.receive(try XCTUnwrap(NotificationHint(payload: ["data": ["v": "2", "makerspace_id": "7", "link": "/items/12"]]))) + await custom.process() + XCTAssertEqual(custom.approved?.link, MobileLink(makerspaceID: 7, destination: .product(12))) + } +} From e4990f33c6052e2c47d13f157fc09cc3ef31d58d Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:48:47 +0530 Subject: [PATCH 88/98] chore(d6): declare collected data in the privacy manifest and seed new copy Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- SpaceWorks/Resources/Localizable.xcstrings | 341 +++++++++++++++++++++ SpaceWorks/Resources/PrivacyInfo.xcprivacy | 113 ++++++- 2 files changed, 443 insertions(+), 11 deletions(-) diff --git a/SpaceWorks/Resources/Localizable.xcstrings b/SpaceWorks/Resources/Localizable.xcstrings index e78539c..3a986a4 100644 --- a/SpaceWorks/Resources/Localizable.xcstrings +++ b/SpaceWorks/Resources/Localizable.xcstrings @@ -1651,6 +1651,17 @@ } } }, + "Confirm it's you": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm it's you" + } + } + } + }, "Confirm password": { "extractionState": "manual", "localizations": { @@ -1783,6 +1794,17 @@ } } }, + "Continue with phone": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Continue with phone" + } + } + } + }, "Copy setup key": { "extractionState": "manual", "localizations": { @@ -1981,6 +2003,17 @@ } } }, + "Delete your whole account on this server": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete your whole account on this server" + } + } + } + }, "Deletion preview": { "extractionState": "manual", "localizations": { @@ -2729,6 +2762,17 @@ } } }, + "Enter the six-digit code from your authenticator app.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter the six-digit code from your authenticator app." + } + } + } + }, "Enter the six-digit code sent to your email.": { "extractionState": "manual", "localizations": { @@ -2740,6 +2784,17 @@ } } }, + "Enter the six-digit code.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter the six-digit code." + } + } + } + }, "Enter the six-digit verification code.": { "extractionState": "manual", "localizations": { @@ -2839,6 +2894,17 @@ } } }, + "Enter your phone number with its country code.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter your phone number with its country code." + } + } + } + }, "Enter your recovery code": { "extractionState": "manual", "localizations": { @@ -3323,6 +3389,17 @@ } } }, + "Include your country code.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Include your country code." + } + } + } + }, "India +91": { "extractionState": "manual", "localizations": { @@ -4324,6 +4401,17 @@ } } }, + "One more step": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "One more step" + } + } + } + }, "One place for your making.": { "extractionState": "manual", "localizations": { @@ -4423,6 +4511,17 @@ } } }, + "Open in the app": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open in the app" + } + } + } + }, "Open inventory update": { "extractionState": "manual", "localizations": { @@ -4841,6 +4940,17 @@ } } }, + "Phone code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Phone code" + } + } + } + }, "Phone number": { "extractionState": "manual", "localizations": { @@ -5820,6 +5930,17 @@ } } }, + "Resolve these first": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resolve these first" + } + } + } + }, "Retry account options": { "extractionState": "manual", "localizations": { @@ -6425,6 +6546,17 @@ } } }, + "Sign in again to delete your account.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in again to delete your account." + } + } + } + }, "Sign in to continue. You may need to confirm your email.": { "extractionState": "manual", "localizations": { @@ -6480,6 +6612,17 @@ } } }, + "Sign out everywhere": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign out everywhere" + } + } + } + }, "Sign-in couldn't be completed. Try again.": { "extractionState": "manual", "localizations": { @@ -6535,6 +6678,17 @@ } } }, + "Signed out on every device and browser.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Signed out on every device and browser." + } + } + } + }, "Signed out on this device. Server revocation could not be confirmed.": { "extractionState": "manual", "localizations": { @@ -6612,6 +6766,17 @@ } } }, + "Something changed. Review the updated list before continuing.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Something changed. Review the updated list before continuing." + } + } + } + }, "Space Works": { "extractionState": "manual", "localizations": { @@ -6997,6 +7162,28 @@ } } }, + "This account cannot be restored after deletion.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This account cannot be restored after deletion." + } + } + } + }, + "This account has no verification method available in the app. Use the web console to delete it.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This account has no verification method available in the app. Use the web console to delete it." + } + } + } + }, "This account requires two-step sign-in.": { "extractionState": "manual", "localizations": { @@ -7085,6 +7272,17 @@ } } }, + "This deletes your whole account on this server, across every makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This deletes your whole account on this server, across every makerspace." + } + } + } + }, "This device": { "extractionState": "manual", "localizations": { @@ -7481,6 +7679,17 @@ } } }, + "This signs you out on every device and browser.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This signs you out on every device and browser." + } + } + } + }, "This summary reflects the recorded handover. Refresh the loan to see its latest status.": { "extractionState": "manual", "localizations": { @@ -7514,6 +7723,17 @@ } } }, + "This version of the app is no longer supported by this server. Update the app to continue.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This version of the app is no longer supported by this server. Update the app to continue." + } + } + } + }, "Ticket": { "extractionState": "manual", "localizations": { @@ -7646,6 +7866,17 @@ } } }, + "Type DELETE to confirm": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Type DELETE to confirm" + } + } + } + }, "Type DELETE to confirm.": { "extractionState": "manual", "localizations": { @@ -8042,6 +8273,17 @@ } } }, + "Verification expired. Try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verification expired. Try again." + } + } + } + }, "Verification method": { "extractionState": "manual", "localizations": { @@ -8075,6 +8317,17 @@ } } }, + "Verify again": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verify again" + } + } + } + }, "Verify identity": { "extractionState": "manual", "localizations": { @@ -8130,6 +8383,28 @@ } } }, + "Verify your identity to continue.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verify your identity to continue." + } + } + } + }, + "Verify your identity to continue. This proof lasts ten minutes.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verify your identity to continue. This proof lasts ten minutes." + } + } + } + }, "View availability": { "extractionState": "manual", "localizations": { @@ -8207,6 +8482,17 @@ } } }, + "We sent a verification code to your phone.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "We sent a verification code to your phone." + } + } + } + }, "We'll send a verification code.": { "extractionState": "manual", "localizations": { @@ -8273,6 +8559,17 @@ } } }, + "You can ask to restore your identity before the retention period ends. Memberships, roles, sessions and notifications stay revoked.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You can ask to restore your identity before the retention period ends. Memberships, roles, sessions and notifications stay revoked." + } + } + } + }, "You haven’t joined a makerspace yet.": { "extractionState": "manual", "localizations": { @@ -8361,6 +8658,28 @@ } } }, + "Your account was deleted.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your account was deleted." + } + } + } + }, + "Your account was deleted. You can ask to restore it before %@ using password recovery.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your account was deleted. You can ask to restore it before %@ using password recovery." + } + } + } + }, "Your card QR code isn't available. Ask the front desk for help.": { "extractionState": "manual", "localizations": { @@ -8372,6 +8691,17 @@ } } }, + "Your code expired. Send a new code.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your code expired. Send a new code." + } + } + } + }, "Your contact details in this makerspace. Changing a phone number here doesn't verify it for sign-in.": { "extractionState": "manual", "localizations": { @@ -8581,6 +8911,17 @@ } } }, + "Your sessions, memberships and notifications end immediately. Payment, loan and audit history is kept anonymised under the retention policy. Leaving one makerspace is a separate action.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your sessions, memberships and notifications end immediately. Payment, loan and audit history is kept anonymised under the retention policy. Leaving one makerspace is a separate action." + } + } + } + }, "Your sign-in expired. Try again.": { "extractionState": "manual", "localizations": { diff --git a/SpaceWorks/Resources/PrivacyInfo.xcprivacy b/SpaceWorks/Resources/PrivacyInfo.xcprivacy index f0db006..0ccaa27 100644 --- a/SpaceWorks/Resources/PrivacyInfo.xcprivacy +++ b/SpaceWorks/Resources/PrivacyInfo.xcprivacy @@ -2,16 +2,107 @@ - NSPrivacyAccessedAPITypes - - - NSPrivacyAccessedAPIType - NSPrivacyAccessedAPICategoryUserDefaults - NSPrivacyAccessedAPITypeReasons - - CA92.1 - - - + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeName + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeEmailAddress + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePhoneNumber + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeUserID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeDeviceID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePhotosorVideos + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeOtherUserContent + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + From cfe013e8dc1b04455b4c725b2121c0715d9837e8 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:05:05 +0530 Subject: [PATCH 89/98] fix(d6): compile deletion probe, add deletion journey and record D-phase docs Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +-- SpaceWorks/AccountDeletion.swift | 5 +- SpaceWorks/AccountDeletionView.swift | 4 +- SpaceWorks/AccountState.swift | 2 +- SpaceWorks/AuthView.swift | 2 +- SpaceWorks/Platform/NativeOIDC.swift | 2 +- SpaceWorks/Resources/Localizable.xcstrings | 11 ++++ SpaceWorks/SpaceWorksApp.swift | 9 ++- SpaceWorksTests/NativeAuthTests.swift | 23 +++++++ SpaceWorksUITests/NativeAuthFlowTests.swift | 23 +++++++ docs/CHANGELOG.md | 69 +++++++++++++++++++++ docs/IMPLEMENTATION-PLAN.md | 15 +++++ docs/NATIVE-SDK-SETUP.md | 40 +++++++----- docs/PUSH-NOTIFICATIONS.md | 27 ++++++-- docs/RELEASE-READINESS.md | 43 +++++++------ docs/STORE-LISTING.md | 16 ++--- docs/WHITE-LABEL.md | 6 +- docs/mobile-auth-ux-spec.md | 4 +- 18 files changed, 249 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 68affba..e436c1f 100644 --- a/README.md +++ b/README.md @@ -24,16 +24,16 @@ Original, unmodified Simulator captures from the successful iPhone and large-iPa | Area | App flows | | --- | --- | -| Getting started | Optional introduction and replay, server selection, password sign-in, MFA and account recovery | +| Getting started | Optional introduction and replay, server selection, minimum-version gate, password, phone, Google, Apple and organization sign-in, MFA and account recovery | | Member workspace | Makerspace switching, inventory and search, loans, requests, spaces, bookings, events, membership card and inbox | | Member actions | Request review and submission, loan renewal, booking, event registration, tickets and profile/preferences | -| Account | Sessions, phone/email verification, membership actions, export and deletion confirmation | -| Staff | Permission-aware request review, handover, check-in, scanning and evidence entry | -| Platform integration | Keychain storage, native document/share presentation, notification routing and payment-provider integration seams | +| Account | Devices and sessions, sign out everywhere, phone/email verification, membership actions, export and whole-account deletion | +| Staff | Native staff sessions: permission-aware request review, handover, check-in, scanning and evidence entry | +| Platform integration | App Attest, Keychain storage, native document/share presentation, push v2 and universal-link routing, server-selected Stripe/Razorpay checkout | Native navigation, forms and system bars adapt to phone and tablet layouts. Space Works semantic colors, supplied static artwork and Gilroy typography support light/dark appearance, Dynamic Type and Bold Text. Motion respects Reduce Motion; feedback follows native platform settings. -**Backend integration is deferred.** Debug includes an explicitly labelled, isolated in-memory demo for reviewing the flows. Release omits demo entry points. Unsupported contracts remain unavailable; see [backend dependencies](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/issues/3). Live attestation, providers, push delivery, checkout and account deletion require deployment configuration and separate verification. +**Live flows use the pinned mobile backend contract** ([provenance](api/provenance.json)). Debug also includes an explicitly labelled, isolated in-memory demo for reviewing the flows; Release omits demo entry points. A few backend limitations remain unavailable by design (optional authenticator enrollment from a device, native provider linking, later pages of sessions/exports); see the [release guide](docs/RELEASE-READINESS.md). Live attestation, providers, push delivery, checkout and account deletion still require deployment configuration and real-device verification. ## Build locally diff --git a/SpaceWorks/AccountDeletion.swift b/SpaceWorks/AccountDeletion.swift index 502b62b..3add91d 100644 --- a/SpaceWorks/AccountDeletion.swift +++ b/SpaceWorks/AccountDeletion.swift @@ -32,7 +32,8 @@ struct LiveAccountDeletionRepository: AccountDeletionRepository { do { _ = try await service.withSession(expectedCredentials: expectedCredentials) { try await service.me() } } catch { if Task.isCancelled || APIError.isCancellation(error) { throw error } - if APIError.status(error) == 401 || (await service.refresh.current() == nil) { await onUnauthorized(APIError.display(error)) } + let signedOut = await service.refresh.current() == nil + if APIError.status(error) == 401 || signedOut { await onUnauthorized(APIError.display(error)) } throw error } throw APIError(status: 0, code: "identity_verification_failed", detail: APIError.display(error), retryAfter: APIError.retryDeadline(error)) @@ -107,7 +108,7 @@ final class AccountDeletionState { var blockers: [Components.Schemas.AccountDeletionBlocker] { preview?.blockers ?? [] } /// Server-listed methods this app can complete natively. TOTP is the password's second factor. var methods: [Method] { - (preview?.reauth.methods ?? []).compactMap { key in + (preview?.reauth.methods ?? []).compactMap { (key: String) -> Method? in switch key { case "password": return .password case "phone": return .phone diff --git a/SpaceWorks/AccountDeletionView.swift b/SpaceWorks/AccountDeletionView.swift index 542719a..2e838e8 100644 --- a/SpaceWorks/AccountDeletionView.swift +++ b/SpaceWorks/AccountDeletionView.swift @@ -5,12 +5,12 @@ import SpaceWorksAPI struct AccountDeletionView: View { @State private var state: AccountDeletionState let origin: ServerOrigin? - let openFix: ((MobileLink) -> Void)? + let openFix: (@MainActor (MobileLink) -> Void)? let memberships: Set @State private var passwordFocused = false @FocusState private var fieldFocused: Bool @Environment(\.dismiss) private var dismiss - init(state: AccountDeletionState, origin: ServerOrigin?, memberships: Set, openFix: ((MobileLink) -> Void)?) { + init(state: AccountDeletionState, origin: ServerOrigin?, memberships: Set, openFix: (@MainActor (MobileLink) -> Void)?) { _state = State(initialValue: state); self.origin = origin; self.memberships = memberships; self.openFix = openFix } var body: some View { diff --git a/SpaceWorks/AccountState.swift b/SpaceWorks/AccountState.swift index 6e9bfe3..b2d32d1 100644 --- a/SpaceWorks/AccountState.swift +++ b/SpaceWorks/AccountState.swift @@ -17,7 +17,7 @@ final class AccountState { @ObservationIgnored var makeDeletion: (@MainActor () -> AccountDeletionState?)? @ObservationIgnored var deletionOrigin: ServerOrigin? @ObservationIgnored var deletionMemberships: Set = [] - @ObservationIgnored var openDeletionFix: ((MobileLink) -> Void)? + @ObservationIgnored var openDeletionFix: (@MainActor (MobileLink) -> Void)? private let beginSecurityEnrollment: (Components.Schemas.MFAChallengeResponse) async -> Void private let endSession: ([String], String?) async -> Void private let membershipEnded: () async -> Void diff --git a/SpaceWorks/AuthView.swift b/SpaceWorks/AuthView.swift index df3d6b3..bf62626 100644 --- a/SpaceWorks/AuthView.swift +++ b/SpaceWorks/AuthView.swift @@ -140,7 +140,7 @@ struct SignInView: View { } else { if auth.hasProviders { ProviderSignInSection(auth: auth, phone: $phoneForm) - if auth.passwordEnabled { Text("or with your email").brandFont(.caption).foregroundStyle(Color.swMuted) } + if auth.passwordEnabled { Text("or sign in with a password").brandFont(.caption).foregroundStyle(Color.swMuted) } } if !auth.passwordEnabled, let error = auth.error { SWError(message: error) } if auth.passwordEnabled { diff --git a/SpaceWorks/Platform/NativeOIDC.swift b/SpaceWorks/Platform/NativeOIDC.swift index 8afba43..2e64744 100644 --- a/SpaceWorks/Platform/NativeOIDC.swift +++ b/SpaceWorks/Platform/NativeOIDC.swift @@ -55,7 +55,7 @@ struct NativeOIDCClient: Sendable { } static func authorization(_ endpoints: Endpoints, clientID: String, nonce: String, - random: (Int) -> Data = Self.randomBytes) -> Authorization? { + random: (Int) -> Data = NativeOIDCClient.randomBytes) -> Authorization? { guard !clientID.isEmpty, !nonce.isEmpty, var parts = URLComponents(url: endpoints.authorization, resolvingAgainstBaseURL: false) else { return nil } let state = base64URL(random(16)) let verifier = base64URL(random(32)) diff --git a/SpaceWorks/Resources/Localizable.xcstrings b/SpaceWorks/Resources/Localizable.xcstrings index 3a986a4..65104e1 100644 --- a/SpaceWorks/Resources/Localizable.xcstrings +++ b/SpaceWorks/Resources/Localizable.xcstrings @@ -8944,6 +8944,17 @@ } } }, + "or sign in with a password": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "or sign in with a password" + } + } + } + }, "or with your email": { "extractionState": "manual", "localizations": { diff --git a/SpaceWorks/SpaceWorksApp.swift b/SpaceWorks/SpaceWorksApp.swift index 498635d..7765543 100644 --- a/SpaceWorks/SpaceWorksApp.swift +++ b/SpaceWorks/SpaceWorksApp.swift @@ -68,7 +68,8 @@ final class PreviewHealthProtocol: URLProtocol { let path = "/" + request.url!.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let failure = arguments.contains("--health-unavailable") && path == "/api/v1/health" let superadmin = arguments.contains("--superadmin") && path == "/api/v1/auth/device/login" - let code = failure ? 503 : superadmin ? 403 : 200 + let deletion = path == "/api/v1/auth/account/deletion" && request.httpMethod == "POST" + let code = failure ? 503 : superadmin ? 403 : deletion ? 202 : 200 let body: String if failure { body = #"{"detail":"Try again later.","code":"service_unavailable"}"# } else if superadmin { body = #"{"detail":"Use the web console.","code":"device_superadmin_not_supported"}"# } @@ -81,6 +82,12 @@ final class PreviewHealthProtocol: URLProtocol { case "/api/v1/auth/phone/login/start": body = #"{"detail":"If the number can sign in, a code was sent."}"# case "/api/v1/auth/phone/login/confirm": body = DebugAuthFixtures.tokens case "/api/v1/auth/logout-all": body = #"{"web_sessions":1,"devices":2}"# + case "/api/v1/auth/account/deletion": + body = deletion ? #"{"deleted_at":"2026-10-18T10:00:00Z","purge_after":"2027-01-16T10:00:00Z"}"# + : #"{"deletable":true,"restorable":true,"restore_reason":"","blockers":[],"reauth":{"required":true,"methods":["password"]}}"# + case "/api/v1/auth/reauth": + body = request.httpMethod == "POST" ? #"{"verified":true,"expires_in":600}"# + : #"{"verified":false,"password_available":true,"mfa_enabled":false,"providers":[]}"# case "/api/v1/bootstrap": body = DebugMemberFixtures.bootstrap case "/api/v1/auth/device/attestation-challenge": body = #"{"challenge":"fresh-test-challenge","expires_in":120}"# case "/api/v1/auth/device/login": body = arguments.contains("--mfa") ? DebugAuthFixtures.mfa : DebugAuthFixtures.tokens diff --git a/SpaceWorksTests/NativeAuthTests.swift b/SpaceWorksTests/NativeAuthTests.swift index 197c9e4..84ad5de 100644 --- a/SpaceWorksTests/NativeAuthTests.swift +++ b/SpaceWorksTests/NativeAuthTests.swift @@ -222,6 +222,29 @@ private final class SeenPaths: @unchecked Sendable { XCTAssertEqual(auth.error, "Signed out on every device and browser.") } + func testAccountDeletionFromTheSignedInSessionClearsThisDevice() async throws { + let storage = MemoryTokens(TokenPair(access: "test-access", refresh: "test-refresh")) + let auth = state(storage: storage) { request in + if let value = self.common(request) { return value } + switch (request.httpMethod ?? "", request.url!.path) { + case ("GET", "/api/v1/auth/account/deletion"): + return (200, #"{"deletable":true,"restorable":true,"restore_reason":"","blockers":[],"reauth":{"required":true,"methods":["password"]}}"#) + case ("GET", "/api/v1/auth/reauth"): return (200, #"{"verified":false,"password_available":true,"mfa_enabled":false,"providers":[]}"#) + case ("POST", "/api/v1/auth/reauth"): return (200, #"{"verified":true,"expires_in":600}"#) + case ("POST", "/api/v1/auth/account/deletion"): return (202, #"{"deleted_at":"2026-10-18T10:00:00Z","purge_after":"2027-01-16T10:00:00Z"}"#) + default: XCTFail("Unexpected API call: \(request.url!)"); return (500, "{}") + } + } + await auth.restore(); XCTAssertEqual(auth.stage, .signedIn) + let deletion = try XCTUnwrap(auth.accountDeletion()) + await deletion.load(); XCTAssertEqual(deletion.stage, .verify) + deletion.password = "password-fixture"; await deletion.verifyPassword() + deletion.confirmation = "DELETE"; await deletion.delete() + XCTAssertEqual(auth.stage, .signIn); XCTAssertNil(auth.user); XCTAssertNil(try storage.load()) + XCTAssertTrue(auth.error?.hasPrefix("Your account was deleted.") == true) + XCTAssertNil(auth.accountDeletion(), "A signed-out device cannot start deletion") + } + func testAppAttestProofKeepsItsAppleTypeInTheOpenVerifierAlternative() throws { let proof = Data(#"{"type":"app_attest","key_id":"a2V5","attestation":"Y2Jvcg=="}"#.utf8) let payload = try DeviceAttestation.payload(proof) diff --git a/SpaceWorksUITests/NativeAuthFlowTests.swift b/SpaceWorksUITests/NativeAuthFlowTests.swift index 8257268..434e243 100644 --- a/SpaceWorksUITests/NativeAuthFlowTests.swift +++ b/SpaceWorksUITests/NativeAuthFlowTests.swift @@ -98,6 +98,29 @@ final class NativeAuthFlowTests: XCTestCase { XCTAssertFalse(app.buttons["staff-tools"].exists) } + func testWholeAccountDeletionVerifiesIdentityAndReturnsToSignIn() { + let app = launch([]) + signIn(app); openProfile(app) + let account = app.buttons["account-entry"] + XCTAssertTrue(account.waitForExistence(timeout: 10)); NativeUIActions.tap(account, in: app) + let delete = app.buttons["account-delete"] + XCTAssertTrue(delete.waitForExistence(timeout: 10)); NativeUIActions.tap(delete, in: app) + let check = app.buttons["deletion-check"] + XCTAssertTrue(check.waitForExistence(timeout: 10)); NativeUIActions.tap(check, in: app) + let password = app.secureTextFields["deletion-password"] + XCTAssertTrue(password.waitForExistence(timeout: 10)); NativeUIActions.tap(password, in: app) + password.typeText("fixture-password\n") + NativeUIActions.tap(app.buttons["deletion-verify"], in: app) + let confirmation = app.textFields["deletion-confirmation"] + XCTAssertTrue(confirmation.waitForExistence(timeout: 10)) + let submit = app.buttons["deletion-submit"] + XCTAssertFalse(submit.isEnabled, "Deletion needs the typed confirmation") + NativeUIActions.tap(confirmation, in: app); confirmation.typeText("DELETE") + attach("Deletion confirmation") + NativeUIActions.tap(submit, in: app) + XCTAssertTrue(app.textFields["username"].waitForExistence(timeout: 10)) + } + func testSignOutEverywhereReturnsToSignIn() { let app = launch([]) signIn(app); openProfile(app) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9fe6ca0..074ff5a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,74 @@ # Phase notes +## D — Mobile backend contract integration (2026-10-18) + +The owner asked for both apps to be finished against the mobile backend (`feature/mobile-backend` at +`f65ba332`), client side only, with no Codex involvement. The backend was read and never modified. + +**D1 contract.** `api/openapi.json` is the backend's `frontend/openapi-schema.json` byte-for-byte +(SHA-256 `962e6a4b…`, 760 paths, recorded in `api/provenance.json`). The generator filter adds social +nonce/Google/Apple/OIDC device sign-in, phone sign-in, logout-all, account deletion and +reauthentication. Generated changes adopted: the booking 201 inline union (`MemberBookingCreated`, +`pending`), the vendor-discriminated `MobilePaymentIntentResponse` (Stripe PaymentSheet or Razorpay +Checkout with the server's minor-unit amount, name and description), the `EventAttendanceResult` +union and new required fields in the Debug member/staff fixtures. A new Python check validates every +member fixture against its operation's response schema. + +**D2 native sign-in.** `PublicConfig.native` drives the sign-in screen: provider buttons render +only when published, the iOS minimum version gates launch before any identity request (stored +credentials are kept), and older servers without `native` keep the password switch. Google, Apple +and organization OIDC share one attested flow: challenge → provider nonce → native credential → +App Attest proof → generated device login; a result without refresh and a UUID device grant is +refused. OIDC uses discovery and code + PKCE in an ephemeral system session against the published +issuer. Phone sign-in binds a fresh challenge to the code and checks its expiry before confirming; +password and phone MFA challenges reuse the existing MFA screens. App Attest proofs now carry +`type: "app_attest"` through the schema's open proof alternative. Superadmin refusals show "Use the +web console". Profile adds "Sign out everywhere". + +**D3 staff.** Staff tools open only for a `native_staff` session whose selected membership grants a +staff action; the existing generated staff repository, queues, handover, scanning and approvals now +run live. A changed authority or action list rebuilds the workspace from a fresh `/auth/me`. + +**D4 account deletion.** Account and security → Delete account shows every server blocker with its +detail, an in-app fix for payment/loan/request/booking/registration blockers in a current +membership and a web-console fix only on the connected deployment's HTTPS origin. Identity proof +uses password (+ authenticator code), the separate phone reauthentication code or a linked native +provider, including a requested second factor. A typed `DELETE` within the proof window deletes; +a conflict refetches the blockers; success clears this device's session. + +**D5 push and links.** APNs routing keys are read from the payload's `data` dictionary (they were +read from the top level, so v2 taps previously fell back). Push, inbox and universal links parse +the backend `mobile_link_contract.py` destinations — shared-host `/m/{slug}/…` resolved through a +fresh membership, and custom-domain forms scoped to the payload's makerspace. + +**D6 hardening.** The privacy manifest declares no tracking and the account-linked data the app +sends for app functionality. New copy is seeded in the English catalog. Documentation records the +owner inputs below. + +Backend limitations kept unavailable (documented, not worked around): optional authenticator +enrollment from a device session; native provider linking; later pages of account sessions/exports +(no `page` parameter); the attestation `type` enum lists only `play_integrity`; the v2 PRD's social +MFA branch does not exist in the schema or the backend. + +Owner inputs still required: Apple Team ID / App ID Prefix and bundle ID, signing and provisioning, +App Attest and Push capabilities, the APNs key and topic on the server, associated domains and the +served association file, Google iOS client ID and reversed scheme, Sign in with Apple native app IDs, +the OIDC native redirect registration, Stripe/Razorpay credentials per makerspace, support/privacy +URLs, store listing and a reviewer account. Live Docker integration was not run: GitHub's macOS +runners cannot reach this machine, and the local stack has no approved native development +registration. + +Parity checklist (shared copy recorded for the Android session): +- [x] Update required / "This version of the app is no longer supported by this server. Update the app to continue." / Check again / Change server. +- [x] Provider buttons before the password form; "Continue with {provider}", "Continue with phone", "or sign in with a password". +- [x] Phone: Phone number, Send verification code, Verification code, Verify, Send another code, Change number; "Enter your phone number with its country code.", "Enter the six-digit code.", "Your code expired. Send a new code." +- [x] Use the web console / "Superadmin accounts cannot sign in on a phone or tablet." / Back to sign in. +- [x] Sign out everywhere / "This signs you out on every device and browser." / "Signed out on every device and browser." +- [x] Staff tools entry only for native staff with a staff action. +- [x] Delete account: Check account obligations, Resolve these first, Open in the app, Open in the web console, Confirm it's you, Type DELETE to confirm, Delete account. +- [x] Push/inbox/universal links follow the contract; unsupported links open the makerspace inbox. +- [x] Light/dark, Dynamic Type headings and 48 pt actions on every new screen; phone and iPad widths constrained. + ## C7 — Preserve independent push and pull-request validation (2026-10-18) The normal push and pull-request workflows shared a concurrency key for the same repository and branch, so the newer pull-request event canceled the same-head push run. GitHub's required-check inventory can retain that canceled push check and require a separate rerun after the pull-request jobs finish. Include the event name in the normal concurrency group so both event types can complete independently, while newer runs still cancel older runs of their own event and branch. Manual validation retains its unique run-ID concurrency group. diff --git a/docs/IMPLEMENTATION-PLAN.md b/docs/IMPLEMENTATION-PLAN.md index 7fb7844..6644c2f 100644 --- a/docs/IMPLEMENTATION-PLAN.md +++ b/docs/IMPLEMENTATION-PLAN.md @@ -42,3 +42,18 @@ an invented restricted-session authority; access-only `verification_only` result The owner requested mobile UX work stay in the mobile repositories. `docs/mobile-auth-ux-spec.md` and `tokens/mobile-motion.json` are versioned here; the Design submodule stays on its existing brand asset/token baseline. New mobile flow and motion changes must not be pushed to Design. + +## D — mobile backend contract integration (2026-10-18) + +The owner supplied the mobile backend (`feature/mobile-backend`, `f65ba332`) and asked for the app +to be finished client side, built by Claude only. The contract is re-pinned to its OpenAPI snapshot +(SHA-256 `962e6a4b…`). + +| Phase | Deliverable | Gate | +|---|---|---| +| D1 | Re-pin contract; adopt booking/payment/attendance unions and new required fields | Generated client compiles; fixtures validate against the schema | +| D2 | `native` config gate and providers; Google/Apple/OIDC/phone device sign-in; App Attest payload; superadmin notice; sign out everywhere | Generated operations only; refuse grants without refresh/device grant | +| D3 | Live staff tools for `native_staff` sessions with staff actions | Server authority plus membership actions | +| D4 | Whole-account deletion with blockers, fixes, fresh proof and typed confirmation | Conflict refetch; session cleared on success | +| D5 | Push v2 `data` payload and contract links for push, inbox and universal links | Fresh membership before navigation | +| D6 | Privacy manifest, copy catalog and owner-input documentation | Owner signing/provider/store inputs remain release gates | diff --git a/docs/NATIVE-SDK-SETUP.md b/docs/NATIVE-SDK-SETUP.md index 3b8d928..3761db4 100644 --- a/docs/NATIVE-SDK-SETUP.md +++ b/docs/NATIVE-SDK-SETUP.md @@ -1,25 +1,37 @@ # Native provider setup SDK code is compiled in CI. A compiled adapter is not evidence of a configured provider or a -completed real-device transaction. The owner deferred the app backend; Debug demo flows never invoke -these SDKs or send their sample values to a service. +completed real-device transaction. Debug demo flows never invoke these SDKs or send their sample +values to a service. + +Sign-in methods render only from the server's `GET /api/v1/config` `native.providers` block +(contract SHA-256 `962e6a4b…`). Every native method uses the same attested device flow: a fresh +attestation challenge, the method's proof, then a device grant through a generated operation. +A response without `refresh` and a UUID `device_grant.id` is refused; no browser cookie or +access-only response is ever accepted as an app session. | Integration | Native adapter | Owner/server setup | |---|---|---| -| Device integrity | DeviceCheck App Attest; fresh key per sign-in, SHA-256 challenge | Registered bundle/team, App Attest capability, server app allowlist and production proof contract | -| Google | Official GoogleSignIn 10.0.0, exact SPM release | Native iOS and server client IDs from approved native config; optional `google_reversed_client_id` in brand.json must be the registered reversed iOS client ID | -| Apple | AuthenticationServices authorization controller | Sign in with Apple capability, native token audience/server fix and approved native config | -| Organization OIDC | Ephemeral ASWebAuthenticationSession, validated state and exact `spaceworks://auth/callback` | Native device delivery callback contract; current web-only callback cannot be substituted | -| Push | UserNotifications/APNs delegate | APNs capability, owner provider key/server link files and server registration setup | +| Device integrity | DeviceCheck App Attest; fresh key per sign-in, `clientDataHash = SHA-256(challenge)`, sent as `{"type":"app_attest","key_id","attestation"}` through the schema's open proof alternative. Simulators in Debug send `{"type":"development"}`. | Approved native app registration (blank makerspace) with the actual App ID Prefix; App Attest capability for the registered bundle/team; development registration for Debug builds | +| Minimum version | `native.min_app_version.ios`, compared by dotted numeric components on launch | Native app registration "Min supported version" (production) | +| Google | Official GoogleSignIn 10.0.0 with the server-issued nonce; the ID token audience is `native.providers.google.ios_client_id` (no server client ID) | Register the iOS OAuth client; set "Google ios client id"; put its reversed client ID in `branding/brand.json` `google_reversed_client_id` so the callback scheme is registered | +| Apple | AuthenticationServices with the server nonce; sends the authorization code (for revocation at deletion) and first-time name | Sign in with Apple capability; list the bundle ID in "Apple native app ids" | +| Organization OIDC | Discovery from the published issuer, authorization code + PKCE (S256) in an ephemeral `ASWebAuthenticationSession`, then the ID token goes to `POST /auth/social/oidc/{slug}`. Discovery and the code exchange talk only to the identity provider. | Add `spaceworks://auth/callback` as a redirect URI on the provider's existing public client (the published `client_id`) | +| Phone | `POST /auth/phone/login/start` with `delivery: device` and a fresh challenge; confirm with the code, the same challenge and its attestation before the challenge expires (180 s by default). Member-only. | Phone login and SMS delivery enabled on the server | +| Push | UserNotifications/APNs; routing keys arrive under the payload's `data` dictionary | APNs key in Platform push settings; `aps_environment` in `branding/brand.json` | | QR | VisionKit scanner and explicit manual-code fallback | Real camera permission/device; server resolves the opaque code | +| Payments | Stripe PaymentSheet or Razorpay Checkout, selected by the intent's `vendor` | Per-makerspace provider credentials on the server; no app secrets | + +Google clears its SDK session after obtaining a token; the app's Keychain device grant remains the +sole app session. Provider tokens are passed straight to the generated operation and are never +logged or persisted. Social sign-in returns tokens only (the backend satisfies MFA through the +provider); password and phone sign-in may return the generated MFA challenge. -Live sign-in buttons are not rendered from web client IDs. The current schema does not include the -native provider/version configuration and social MFA union; update the authoritative snapshot when -the owner supplies them. Google refuses to launch without its registered callback scheme and clears -its SDK session after obtaining a token; the app's Keychain device grant remains the sole app session. -Provider operations return opaque identity tokens to the generated-client integration seam; tokens -are never logged or added to defaults. The original PRD remains unchanged. +Reauthentication for account deletion supports password (+ authenticator code), the separate phone +reauthentication code, and native Google/Apple/organization proof with the server's reauthentication +nonce, including a requested second factor. GitHub proof is web-only. Sources: [Google release](https://github.com/google/GoogleSignIn-iOS/releases/tag/10.0.0), [Google integration](https://developers.google.com/identity/sign-in/ios/sign-in), -[App Attest](https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity). +[App Attest](https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity), +[RFC 8252](https://www.rfc-editor.org/rfc/rfc8252). diff --git a/docs/PUSH-NOTIFICATIONS.md b/docs/PUSH-NOTIFICATIONS.md index 2b0a568..3b6333c 100644 --- a/docs/PUSH-NOTIFICATIONS.md +++ b/docs/PUSH-NOTIFICATIONS.md @@ -65,11 +65,28 @@ associated-domain file. Simulator stubs and Debug fixtures do not prove real APNs delivery or production authorization. The existing inbox remains available without notification permission or push configuration. -The pinned hint payload identifies a makerspace but does not identify its deployment origin. -Offline logout can leave an old server registration, and the OS may later reuse its APNs token. -The app reauthorizes and fetches all content from the current server before navigation; precise -cross-deployment attribution still needs an origin-bound server hint contract. Keep live push -unconfigured until the owner supplies and verifies that deployment contract. +## Payload and links (push v2) + +The server sends `{"aps":{"alert":{title,body},"thread-id":""},"data":{…}}`. +`thread-id` groups notifications per makerspace. `data` carries string values `v` ("2"), +`event_id`, `makerspace_id`, `feature`, `event`, member-only `inbox_row_id` and an optional `link`. +A payload without `v` is treated as v1 (makerspace inbox only); flat legacy payloads are still read. +Unknown keys are ignored and the body text is never trusted for display or routing. + +`link` follows the backend's `mobile_link_contract.py`: shared-host `/m/{slug}/member`, +`/m/{slug}/member/{tab}` (overview, loans, bookings, events, payments, inbox, account), +`/m/{slug}/member/events/{registration}/ticket`, `/m/{slug}/events/{token}`, +`/m/{slug}/bookings/{token}` and `/m/{slug}/items/{item}`, plus the custom-domain forms without +`/m/{slug}`. The slug must resolve to the payload's makerspace in a fresh `/auth/me`; a custom-domain +path uses the payload's makerspace. Anything else (staff admin, control, API, query strings, +unknown destinations) opens that makerspace's inbox. Universal links on the connected deployment +host use the same parser; inbox rows use it too. A custom-domain universal link cannot be mapped to +a makerspace on its own and is refused. + +The payload identifies a makerspace but not its deployment origin. Offline logout can leave an old +server registration, and the OS may later reuse its APNs token. The app reauthorizes and fetches all +content from the current server before navigation. The server publishes no notification categories, +so the app registers none. The live registration repository captures its owning credential installation epoch when identity is validated. Queued work cannot register or delete using a replacement account's credentials, even if diff --git a/docs/RELEASE-READINESS.md b/docs/RELEASE-READINESS.md index 11c756e..1da41f0 100644 --- a/docs/RELEASE-READINESS.md +++ b/docs/RELEASE-READINESS.md @@ -78,19 +78,24 @@ App Store Connect API key, APNs key, test password or service credential. | Camera/evidence | Archive includes the purpose string; test first request, denial, later Settings enablement, no hardware, rotation, cancellation and manual entry. Upload only explicitly confirmed evidence through documented operations. | | Payments | Server selects vendor and supplies runtime public checkout values; secrets remain on the server. Verify configured sandbox success, cancellation, failure, delayed webhook and later status refresh. SDK success alone never proves paid status. | -The repository backend-request issue tracks the missing contract work: -[native backend dependencies](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/issues/3). -Native minimum-version/provider configuration, staff device authority, push v2/link files, -Razorpay checkout fields and whole-account deletion need the owner's supported contracts. -Account management also has confirmed native limitations: the current backend refuses optional -authenticator enrollment from a device session and provider linking from a native client. Native -login enrollment challenges remain supported. Account session/export list schemas omit pagination -parameters; later pages must wait for a published contract. The app must keep these live paths -explicitly unavailable and never impersonate a browser client. Required-MFA disable controls stay -hidden; recovery-code replacement and supported MFA changes clear old sessions before returning -to sign-in. A wrong management password must not destroy a separately verified valid session. -Debug demo journeys exercise UI states with labelled in-memory data; they do not validate -attestation, authentication delivery, push, charges or deletion against a real service. +The pinned contract (SHA-256 `962e6a4b…`, backend `feature/mobile-backend` at `f65ba332`) supplies +the native contracts the earlier [backend dependencies issue](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/issues/3) +requested. The app now uses them through generated operations: `PublicConfig.native` provider +discovery and the minimum-version gate, App Attest proofs, Google/Apple/organization OIDC and phone +device sign-in, `session_authority` native staff tools, sign out everywhere, vendor-selected +Stripe/Razorpay intents, push v2 payloads with contract links, and whole-account deletion with +blockers, fresh reauthentication and typed confirmation. + +Remaining backend limitations, documented rather than worked around: optional authenticator +enrollment from a device session is still refused (login-time enrollment works); provider linking +still requires a browser client; account session/export list operations still declare no `page` +parameter, so only the first page is shown; the shared attestation `type` enum still lists only +`play_integrity`, so Apple proofs use the schema's open alternative. The app never impersonates a +browser client. Required-MFA disable controls stay hidden; recovery-code replacement and supported +MFA changes clear old sessions before returning to sign-in. A wrong management or reauthentication +password never ends a separately verified valid session. Debug demo journeys exercise UI states with +labelled in-memory data; they do not validate attestation, authentication delivery, push, charges or +deletion against a real service. ## Privacy and store content @@ -110,10 +115,12 @@ as the app's practices. [App privacy](https://developer.apple.com/help/app-store | Activity/payments | Requests, loans, bookings, event attendance, payment metadata and legally retained records; provider-specific processing. | | Diagnostics | Verify actual SDK defaults and server logs; no app analytics was authorized. Do not assume dependencies never transmit diagnostics. | -Account creation requires a genuine in-app way to initiate whole-account deletion. A membership -leave action, disabled placeholder or demo completion does not satisfy that requirement. The -owner-deferred deletion contract remains a release gate; implement the PRD's blocker list, -fix links, reauthentication, confirmation and anonymized retained-record explanation when supplied. +Account creation requires a genuine in-app way to initiate whole-account deletion. Account and +security → Delete account now runs the server's flow: every blocker with its server detail and +in-app or web-console fix, fresh identity proof (password and authenticator code, phone code or a +linked native provider, plus any requested second factor), the restore explanation and a typed +`DELETE` confirmation. A conflict at confirmation refetches the blockers; success clears the device +session. Verify it against the production deployment and a reviewer account before submission. [Apple account deletion guidance](https://developer.apple.com/help/app-review/guideline-reference/5-1-1-account-deletion). Supply the real support/privacy URLs, description, subtitle, category, age/content answers, @@ -151,5 +158,5 @@ Record distribution evidence here after owner provisioning; no credentials belon | Full device-matrix CI run | The final delivery ledger must link the accepted exact-source six-profile run, canonical `dev` push validation and actually reviewed artifacts; a pending or failed run does not establish acceptance. Merge the exact reviewed head through existing iOS pull request #4 only after the required repository gates pass; keep `dev` and phase commits. | | Signed archive / version / build | Owner signing environment required | | Real-device provider test record | Owner backend/configuration required | -| Whole-account deletion verification | Updated owner contract required | +| Whole-account deletion verification | Implemented against the pinned contract; real-deployment verification by the owner required | | TestFlight build / tester acceptance | Owner App Store Connect access required | diff --git a/docs/STORE-LISTING.md b/docs/STORE-LISTING.md index e19ad3c..a913f6f 100644 --- a/docs/STORE-LISTING.md +++ b/docs/STORE-LISTING.md @@ -1,7 +1,8 @@ # App Store listing draft -Prepared 17 October 2026 for owner review and later deployment. The backend and store -provisioning remain deferred. Use this copy only for features verified in the submitted build. +Prepared 17 October 2026 for owner review; updated 18 October 2026 after the app adopted the +mobile backend contract. Store provisioning remains with the owner. Use this copy only for features +verified in the submitted build and deployment. ## Product fields @@ -43,9 +44,10 @@ Space Works adapts to phones and tablets, with light and dark appearance and an You need a compatible makerspace server and an account. Member features require the relevant makerspace membership. Available tools, services and actions depend on your makerspace. Internet access is required for current availability and protected actions. ``` -This is the base member listing. Do not add unsupported native provider, offline-cache, -whole-account deletion or live staff claims. Add the following staff paragraph only after the -owner's native authority contract and the submitted deployment actually enable those controls: +This is the base member listing. Do not add offline-cache claims. Mention a sign-in provider only +when the submitted deployment publishes it. In-app whole-account deletion is available; verify it +with the reviewer account before submission. Add the following staff paragraph only when the +submitted deployment grants native staff sessions and the reviewer can see those controls: ```text Authorized staff can review requests and approvals, check items before handover or return, and confirm attendance. Scanning finds a record; staff review and confirm each action. @@ -76,8 +78,8 @@ another pull request or feature branch for this batch. ## Reviewer access and review notes The in-app **Explore demo** is a Debug-only, in-memory preview. Its sample credentials do not -authenticate the signed Release build. The owner-deferred backend must provide a stable real -reviewer account and makerspace before submission. Store credentials only in the private console +authenticate the signed Release build. The owner must provide a stable real reviewer account and +makerspace on the submitted deployment before submission. Store credentials only in the private console app-access/review fields, never in this repository. | Private console input | Owner-provisioned value | diff --git a/docs/WHITE-LABEL.md b/docs/WHITE-LABEL.md index ebc8896..008b410 100644 --- a/docs/WHITE-LABEL.md +++ b/docs/WHITE-LABEL.md @@ -12,7 +12,7 @@ Mobile UX guidance is maintained in this app's `docs/mobile-auth-ux-spec.md`, an 6. Create an APNs key in your own Apple account and configure it privately on your server. iOS uses APNs directly; it does not need a Firebase project. Self-hosters publishing Android separately need their own Firebase project for Android FCM. Push routing also needs the published native contract and server link files; see the backend dependency issue in the release guide. 7. Inject signing certificates/provisioning profiles and `.p8` files from CI secrets. Never commit keys, tokens, provisioning files, or `google-services.json`. Simulator tests use a temporary ad-hoc `LOCALCI` Keychain identity; it is not an Apple team identity or distribution credential. 8. Run the CI checks and archive a Release build using your Apple signing configuration. Check your deployment URL, dark/light themes, VoiceOver and the largest Dynamic Type size on a device. Supply support/privacy URLs and your own App Store privacy declarations, screenshots and reviewer demo account. -9. Upload an owner-signed, validated archive to your App Store Connect account and TestFlight first. Require the recorded phase checks and owner-provisioned integration tests before publication. Whole-account deletion and other missing native contracts remain release gates; an unsigned CI compile or demo journey is not a submitted build. +9. Upload an owner-signed, validated archive to your App Store Connect account and TestFlight first. Require the recorded phase checks and owner-provisioned integration tests before publication, including whole-account deletion against your deployment. An unsigned CI compile or demo journey is not a submitted build. Optional deployment links and artwork are configured in the same `branding/brand.json`: @@ -24,8 +24,8 @@ Run the generator tests as well as generation after changing branding. Test a fr App content uses the owner's supplied Gilroy native faces with Dynamic Type and Bold Text support. Native bars/system activities retain platform conventions; IDs and secrets use Gilroy with tabular digits, not a monospace family. Gilroy and the Unbounded accent font conversion provenance are in `SpaceWorks/Resources/Fonts/`; upstream licenses still apply and this repository grants no new font rights. Brand assets retain their upstream licenses; this repo does not grant additional embedding rights. Keep master logos static, undistorted and fully opaque with the Design clear space; motion belongs on sibling content and respects Reduce Motion. -Register the real Google iOS OAuth audience and optional `google_reversed_client_id` callback scheme. Enable Sign in with Apple for the registered bundle/team. Organization sign-in needs its actual native callback/device-grant contract. Compiled SDK dependencies do not enable a web-only server flow automatically. Never substitute a browser cookie or access-only response for a complete native grant. +Sign-in buttons follow your server's `native.providers` configuration. Register the real Google iOS OAuth client, enter it as the server's "Google ios client id" and set its reversed ID as `google_reversed_client_id`. Enable Sign in with Apple for the registered bundle/team and list the bundle ID in "Apple native app ids". For organization OIDC, add `spaceworks://auth/callback` as a redirect URI on the provider's public client. Phone sign-in needs the server's phone login and SMS delivery. See [NATIVE-SDK-SETUP.md](NATIVE-SDK-SETUP.md). Never substitute a browser cookie or access-only response for a complete native grant. Native capability generation supports optional `aps_environment` (`null` or `"production"`) and `associated_domains` (an explicit array of owned `applinks:`/`webcredentials:` DNS names). When push is enabled, Debug uses the development APNs environment and Release uses production. Configured capabilities require the owner's real ten-character Apple team ID and matching provisioning. Leave these inputs absent until the deployment is configured. Supply the actual associated-domain files on the owner's server; generating an entitlement does not verify them. Edit these deployment inputs instead of hand-editing generated entitlement files, which the prebuild generator recreates. See [PUSH-NOTIFICATIONS.md](PUSH-NOTIFICATIONS.md) for consent, returned registration IDs, cleanup and membership routing. -`SpaceWorks/Resources/PrivacyInfo.xcprivacy` declares same-app UserDefaults access (`CA92.1`) for locally stored preferences such as theme, server selection and introduction completion. It does not declare that production collects no data. Review app collection and linked SDK manifests against the actual deployment before store submission; [Apple's required-reason API guidance](https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api) explains the declaration. +`SpaceWorks/Resources/PrivacyInfo.xcprivacy` declares same-app UserDefaults access (`CA92.1`) for locally stored preferences such as theme, server selection and introduction completion, no tracking, and the data the app itself sends to your server for app functionality (name, email, phone, user and device identifiers, staff evidence photos and other member-entered content), all linked to the account. Review app collection and linked SDK manifests against the actual deployment before store submission; [Apple's required-reason API guidance](https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api) explains the declaration. diff --git a/docs/mobile-auth-ux-spec.md b/docs/mobile-auth-ux-spec.md index c42ded2..ad7c090 100644 --- a/docs/mobile-auth-ux-spec.md +++ b/docs/mobile-auth-ux-spec.md @@ -144,7 +144,7 @@ Provider order is configured Google, configured Apple on its supported platform, | Google Android | Credential Manager with official Google assets/button wording. Use Web OAuth client ID as `serverClientId`; Android package/SHA setup is separately required. A tap opens the native chooser, not a custom account list. Obtain fresh server nonce and challenge, get ID token, then generated exchange. | Existing `web_client_id` may be the correct SDK audience. Native attestation envelope and native/MFA response completeness still need validation; missing native refresh/grant must not be accepted as an authenticated app session. | | Google iOS | Real configured Google SDK/system browser flow, correct iOS client configuration and server audience; official button asset; same generated exchange authority | Do not substitute the Android/web client ID for required iOS setup. Unsupported setup is a clear unavailable/demo branch, never fake credential entry. | | Apple | System `SignInWithAppleButton`/AuthenticationServices, approved white or white-outline style, height48, radius10, full accessible title. Surrounding brand panel can use Space Works colors. Bind and validate nonce through the generated gateway. | No hand-drawn Apple glyph or teal Apple-branded button. Apple response cancellation is neutral. Account linking/MFA still requires server-native readiness. | -| Enterprise OIDC | Button “Continue with {configured provider name}”; trusted configured issuer; OS browser authentication session, fresh state/nonce and PKCE when the actual flow requires it; verified redirect and callback ownership | Pinned browser authorize/callback models do not specify native refresh/device-grant delivery. Do not turn browser cookies or a web access token into native access. Unavailable until a real compatible generated exchange exists. | +| Enterprise OIDC | Button “Continue with {configured provider name}”; trusted configured issuer; OS browser authentication session, fresh state/nonce and PKCE when the actual flow requires it; verified redirect and callback ownership | Native delivery uses the published provider (`native.providers.oidc`): discovery, code + PKCE in the system session, then the ID token through the generated `social/oidc/{slug}` device operation. Never turn browser cookies or a web access token into native access. | | Phone | “Continue with phone”; country-aware phone entry, explicit send, code screen with change-number/resend/cancel. Explain “We'll send a verification code.” | `PhoneStart`/`PhoneConfirm` exist but native device proof/grant contract is incomplete. Full flow is demo-previewable; no production phone-session shortcut. No invented SMS expiry fields. | Provider assets retain official styling, including Google multicolor glyph. Do not recolor provider marks to teal or replace them with ordinary letters. See [Google branding](https://developers.google.com/identity/branding-guidelines) and [Apple sign-in guidance](https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple/). @@ -178,7 +178,7 @@ Use the generated operation/model from the pinned contract; names differ between Frontend evidence: `features/auth/PasswordRecoveryFlow.tsx`, `features/auth/passwordRecovery.ts`, `features/auth/NewPasswordFields.tsx`, `features/auth/MfaFlow.tsx`, `features/members/MemberAuthPanel.tsx`, and `features/staff/ChangePasswordGate.tsx`. Password recovery uses a local60-second resend delay and the exact uniform rejection above. Signup visibility follows configured `self_registration`/`member_accounts`; restricted verification remains a gate. Read frontend flows for parity; do not copy browser authentication storage or cookie transport into mobile. -Backend-deferred boundaries remain explicit: native attestation envelope, native phone/OIDC exchange and complete social MFA grant delivery, native/min-version config shape, push server association/link files, staff authority where not represented, vendor server checkout inputs, and account deletion blockers/reauth/execute. A beautiful screen never grants authority these contracts do not provide. +The contract at SHA-256 `962e6a4b…` supplies the native attestation envelope, phone/OIDC device exchange, native/min-version configuration, push v2 links, `session_authority` staff access, vendor checkout inputs and account deletion blockers/reauth/execute; the app uses them only through generated operations. Social device sign-in returns tokens without an MFA branch. Optional device MFA enrollment, native provider linking and session/export paging remain backend limitations. A beautiful screen never grants authority these contracts do not provide. ## 8. Error, offline, and cooldown behavior From dcb73314240eafdcf68b64412659ed19f805ef82 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:23:45 +0530 Subject: [PATCH 90/98] test(d2): import the API module for identity provider error assertions Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- SpaceWorksTests/NativeIdentityTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/SpaceWorksTests/NativeIdentityTests.swift b/SpaceWorksTests/NativeIdentityTests.swift index e13df29..6dd753f 100644 --- a/SpaceWorksTests/NativeIdentityTests.swift +++ b/SpaceWorksTests/NativeIdentityTests.swift @@ -1,4 +1,5 @@ import CryptoKit +import SpaceWorksAPI import XCTest import UIKit @testable import SpaceWorks From 4e5426961ab7f9ca6e5ef54d78f6797cca2fc268 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:12:28 +0530 Subject: [PATCH 91/98] test(d5): align inbox landing link refusals with the server link contract Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- SpaceWorksTests/MobileLinkTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/SpaceWorksTests/MobileLinkTests.swift b/SpaceWorksTests/MobileLinkTests.swift index 2744e17..3f7ef32 100644 --- a/SpaceWorksTests/MobileLinkTests.swift +++ b/SpaceWorksTests/MobileLinkTests.swift @@ -37,7 +37,9 @@ final class MobileLinkTests: XCTestCase { func testExistingInboxLandingLinksOnlyParseExactSafeCurrentOriginShapes() { XCTAssertEqual(MobileLink.legacy("/m/fab-lab-kochi/member", origin: origin), .init(slug: "fab-lab-kochi", staffInbox: false)) XCTAssertEqual(MobileLink.legacy("https://spaceworks.example:443/m/fab-lab-kochi/admin/notifications", origin: origin), .init(slug: "fab-lab-kochi", staffInbox: true)) - for value in ["https://evil.example/m/fab-lab-kochi/member", "https://user@spaceworks.example/m/fab-lab-kochi/member", "/m/fab-lab-kochi/member?token=secret", "/m/fab-lab-kochi/member/extra", "/m/fab%2dlab/member", "/m/../member", "/m/fab\\lab/member", "//m/fab/member"] { XCTAssertNil(MobileLink.legacy(value, origin: origin)) } + // mobile_link_contract.py claims any single member tab; an unknown tab opens Home like the web portal. + XCTAssertEqual(MobileLink.legacy("/m/fab-lab-kochi/member/extra", origin: origin), .init(slug: "fab-lab-kochi", staffInbox: false)) + for value in ["https://evil.example/m/fab-lab-kochi/member", "https://user@spaceworks.example/m/fab-lab-kochi/member", "/m/fab-lab-kochi/member?token=secret", "/m/fab-lab-kochi/member/extra/more", "/m/fab%2dlab/member", "/m/../member", "/m/fab\\lab/member", "//m/fab/member"] { XCTAssertNil(MobileLink.legacy(value, origin: origin)) } } func testEncodedTraversalControlCharactersAndInvalidMakerIDsAreRefused() { for value in ["//evil.example/makerspaces/7/inbox", "/makerspaces/7/../profile", "/makerspaces/7/./inbox", "/makerspaces/7/%2einbox", "/makerspaces/7/inbox\n", "/makerspaces/0/inbox", "/makerspaces/-7/inbox", "/makerspaces/999999999999999999999999999999/inbox", "/makerspaces/7//inbox"] { XCTAssertNil(MobileLink.path(value)) } From 252376ec78188dac4491816a05d8d557cbadc42f Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:39:56 +0530 Subject: [PATCH 92/98] fix(d2): keep the phone sheet on the auth root and dismiss deletion keyboard Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- SpaceWorks/AccountDeletionView.swift | 1 + SpaceWorks/AuthState.swift | 2 ++ SpaceWorks/AuthView.swift | 9 ++++----- SpaceWorksUITests/NativeAuthFlowTests.swift | 4 +++- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/SpaceWorks/AccountDeletionView.swift b/SpaceWorks/AccountDeletionView.swift index 2e838e8..048cb5e 100644 --- a/SpaceWorks/AccountDeletionView.swift +++ b/SpaceWorks/AccountDeletionView.swift @@ -112,6 +112,7 @@ struct AccountDeletionView: View { Text("Type DELETE to confirm").brandFont(.headline) TextField("Type DELETE", text: $state.confirmation).textInputAutocapitalization(.characters).autocorrectionDisabled().focused($fieldFocused) .padding(16).background(Color.swPanel, in: RoundedRectangle(cornerRadius: 10)).overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)) + .submitLabel(.done).onSubmit { fieldFocused = false } .accessibilityIdentifier("deletion-confirmation") Button(state.busy ? "Deleting…" : "Delete account", role: .destructive) { Task { await state.delete() } } .buttonStyle(SWPrimary()).disabled(!state.canDelete).accessibilityIdentifier("deletion-submit") diff --git a/SpaceWorks/AuthState.swift b/SpaceWorks/AuthState.swift index 038686d..91781bc 100644 --- a/SpaceWorks/AuthState.swift +++ b/SpaceWorks/AuthState.swift @@ -36,6 +36,8 @@ final class AuthState { private let serverScope: String private let origin: ServerOrigin private(set) var notifications: NotificationSettingsState? + /// The phone sign-in sheet, presented from the persistent auth root. + var phoneForm: PhoneSignInState? private(set) var foregroundHintRevision = 0 private(set) var inboxRefreshRevision = 0 private(set) var inboxRefreshMakerspaceID: Int? diff --git a/SpaceWorks/AuthView.swift b/SpaceWorks/AuthView.swift index bf62626..6cd67db 100644 --- a/SpaceWorks/AuthView.swift +++ b/SpaceWorks/AuthView.swift @@ -36,7 +36,9 @@ struct AuthRoot: View { } .nativeFeedback(auth.feedback) .task { await auth.restore(); bindRouting() } - .onChange(of: auth.stage) { _, _ in bindRouting() } + // The phone sheet belongs to this persistent root so it closes when sign-in leaves this stage. + .sheet(item: $auth.phoneForm) { form in PhoneSignInView(form: form) } + .onChange(of: auth.stage) { _, stage in if stage != .signIn { auth.phoneForm = nil }; bindRouting() } .onChange(of: auth.user?.id) { _, _ in bindRouting() } .onChange(of: auth.foregroundHintRevision) { _, _ in Task { await auth.processForegroundHint() } } .onChange(of: auth.busy) { _, busy in if !busy { Task { await auth.processForegroundHint() } } } @@ -118,7 +120,6 @@ struct SignInView: View { @State private var showPassword = false @State private var passwordFocused = false @State private var accountForm: AccountAccessState? - @State private var phoneForm: PhoneSignInState? @Environment(\.dynamicTypeSize) private var typeSize var body: some View { GeometryReader { geometry in @@ -139,7 +140,7 @@ struct SignInView: View { Text("Ask your makerspace for help, or check the server address.").foregroundStyle(Color.swMuted) } else { if auth.hasProviders { - ProviderSignInSection(auth: auth, phone: $phoneForm) + ProviderSignInSection(auth: auth, phone: $auth.phoneForm) if auth.passwordEnabled { Text("or sign in with a password").brandFont(.caption).foregroundStyle(Color.swMuted) } } if !auth.passwordEnabled, let error = auth.error { SWError(message: error) } @@ -194,8 +195,6 @@ struct SignInView: View { }.background(Color.swBg).scrollDismissesKeyboard(.interactively).accessibilityIdentifier("sign-in-scroll") } .sheet(item: $accountForm) { form in AccountAccessView(form: form) { auth.username = form.email; accountForm = nil } } - .sheet(item: $phoneForm) { form in PhoneSignInView(form: form) } - .onChange(of: auth.stage) { _, stage in if stage != .signIn { phoneForm = nil } } } private func field(_ title: String, @ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 8) { diff --git a/SpaceWorksUITests/NativeAuthFlowTests.swift b/SpaceWorksUITests/NativeAuthFlowTests.swift index 434e243..679b6a3 100644 --- a/SpaceWorksUITests/NativeAuthFlowTests.swift +++ b/SpaceWorksUITests/NativeAuthFlowTests.swift @@ -68,6 +68,8 @@ final class NativeAuthFlowTests: XCTestCase { XCTAssertTrue(space.waitForExistence(timeout: 10)); NativeUIActions.tap(space, in: app) XCTAssertTrue(app.staticTexts["Welcome back, Maker."].waitForExistence(timeout: 10)) if app.tabBars.buttons["Profile"].exists { app.tabBars.buttons["Profile"].tap(); return } + // Tablet portrait hides the sidebar; the live shell keeps it visible in landscape. + app.rotateToLandscapeAndWait() let item = app.buttons["nav-Profile"] let readiness = NativeUIActions.readiness(in: app) if XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: readiness, object: item)], timeout: 3) != .completed { @@ -115,7 +117,7 @@ final class NativeAuthFlowTests: XCTestCase { XCTAssertTrue(confirmation.waitForExistence(timeout: 10)) let submit = app.buttons["deletion-submit"] XCTAssertFalse(submit.isEnabled, "Deletion needs the typed confirmation") - NativeUIActions.tap(confirmation, in: app); confirmation.typeText("DELETE") + NativeUIActions.tap(confirmation, in: app); confirmation.typeText("DELETE\n") attach("Deletion confirmation") NativeUIActions.tap(submit, in: app) XCTAssertTrue(app.textFields["username"].waitForExistence(timeout: 10)) From 85c6c372b0f63fa4fa3c3661aa8c2ba0dfb94d92 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:02:40 +0530 Subject: [PATCH 93/98] feat(d7): opt-in Face ID and passcode app lock per account Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- SpaceWorks/AuthState.swift | 6 +- SpaceWorks/AuthView.swift | 10 +- SpaceWorks/Platform/NativeAppLock.swift | 97 +++++++++++++++++++ SpaceWorks/Resources/Localizable.xcstrings | 88 +++++++++++++++++ .../Resources/en.lproj/InfoPlist.strings | 1 + SpaceWorks/RootView.swift | 5 + SpaceWorksTests/NativeAppLockTests.swift | 39 ++++++++ docs/CHANGELOG.md | 5 + scripts/audit_bundle.py | 1 + scripts/generate.py | 1 + 10 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 SpaceWorks/Platform/NativeAppLock.swift create mode 100644 SpaceWorksTests/NativeAppLockTests.swift diff --git a/SpaceWorks/AuthState.swift b/SpaceWorks/AuthState.swift index 91781bc..c66d546 100644 --- a/SpaceWorks/AuthState.swift +++ b/SpaceWorks/AuthState.swift @@ -38,6 +38,8 @@ final class AuthState { private(set) var notifications: NotificationSettingsState? /// The phone sign-in sheet, presented from the persistent auth root. var phoneForm: PhoneSignInState? + /// Optional Face ID / Touch ID / passcode lock for the signed-in account on this device. + private(set) var appLock: NativeAppLock? private(set) var foregroundHintRevision = 0 private(set) var inboxRefreshRevision = 0 private(set) var inboxRefreshMakerspaceID: Int? @@ -559,6 +561,8 @@ final class AuthState { user = identity; selectedID = selection stage = identity.must_change_password ? .passwordChange : .signedIn if stage == .signedIn { configureNotifications(accountID: identity.id, credentials: credentials) } + let lockScope = serverScope + ".\(identity.id)" + if appLock?.scope != lockScope { appLock = NativeAppLock(scope: lockScope, settings: settings) } } /// Push registration, foreground refresh and notification taps share one current identity read. /// Publication and busy-state release happen inside the shared task before any waiter proceeds. @@ -679,7 +683,7 @@ final class AuthState { settings.removeObject(forKey: selectionKey) locallyLeftMemberships = []; securityMessage = nil; acceptedCredentials = nil user = nil; selectedID = nil; mfa = nil; mfaExpires = nil; enrollment = nil; recoveryCodes = [] - username = ""; password = ""; code = ""; stage = .signIn + username = ""; password = ""; code = ""; appLock = nil; stage = .signIn } private func clear() async { let cleanup = detachNotificationCleanup() diff --git a/SpaceWorks/AuthView.swift b/SpaceWorks/AuthView.swift index 6cd67db..ea804b0 100644 --- a/SpaceWorks/AuthView.swift +++ b/SpaceWorks/AuthView.swift @@ -34,6 +34,11 @@ struct AuthRoot: View { } } } + .overlay { + if auth.stage == .signedIn, let lock = auth.appLock, lock.locked { + AppLockView(lock: lock) { Task { await auth.signOut() } } + } + } .nativeFeedback(auth.feedback) .task { await auth.restore(); bindRouting() } // The phone sheet belongs to this persistent root so it closes when sign-in leaves this stage. @@ -43,7 +48,10 @@ struct AuthRoot: View { .onChange(of: auth.foregroundHintRevision) { _, _ in Task { await auth.processForegroundHint() } } .onChange(of: auth.busy) { _, busy in if !busy { Task { await auth.processForegroundHint() } } } .onDisappear { linkRouter.unbind(owner: routingOwner); Task { await auth.stopNotifications() } } - .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await auth.foreground() } } } + .onChange(of: scenePhase) { _, phase in + if phase == .background { auth.appLock?.lock() } + if phase == .active { Task { if auth.appLock?.locked == true { await auth.appLock?.unlock() }; await auth.foreground() } } + } } private func bindRouting() { NativeNotifications.shared.onTap = { hint in linkRouter.receive(hint) } diff --git a/SpaceWorks/Platform/NativeAppLock.swift b/SpaceWorks/Platform/NativeAppLock.swift new file mode 100644 index 0000000..f070ed4 --- /dev/null +++ b/SpaceWorks/Platform/NativeAppLock.swift @@ -0,0 +1,97 @@ +import Foundation +import LocalAuthentication +import Observation +import SwiftUI + +/// Optional device-owner lock for a signed-in account. It hides app content after backgrounding until +/// Face ID, Touch ID, Optic ID or the device passcode succeeds. It never stores or sends biometric data. +@MainActor @Observable +final class NativeAppLock { + enum Kind: Equatable { case faceID, touchID, opticID, passcode, unavailable } + let scope: String + let kind: Kind + private(set) var enabled: Bool + private(set) var locked: Bool + private(set) var busy = false + private(set) var error: String? + private let settings: UserDefaults + private let evaluate: @MainActor (String) async -> Bool + + init(scope: String, settings: UserDefaults = .standard, kind: Kind = NativeAppLock.availableKind(), + evaluate: @escaping @MainActor (String) async -> Bool = NativeAppLock.deviceOwner) { + self.scope = scope; self.settings = settings; self.kind = kind; self.evaluate = evaluate + let stored = kind != .unavailable && settings.bool(forKey: "app-lock." + scope) + enabled = stored + locked = stored // A cold launch with the lock on starts locked. + } + + var available: Bool { kind != .unavailable } + var title: String { + switch kind { + case .faceID: return String(localized: "Require Face ID") + case .touchID: return String(localized: "Require Touch ID") + case .opticID: return String(localized: "Require Optic ID") + case .passcode, .unavailable: return String(localized: "Require device passcode") + } + } + private var reason: String { String(localized: "Unlock your makerspace account.") } + + /// Turning the lock on proves the owner can unlock first; turning it off needs no proof. + func setEnabled(_ value: Bool) async { + guard available, !busy, value != enabled else { return } + if value { + busy = true; let allowed = await evaluate(reason); busy = false + guard allowed else { error = String(localized: "App lock was not turned on."); return } + } + error = nil; enabled = value; locked = false + settings.set(value, forKey: "app-lock." + scope) + } + + func lock() { if enabled { locked = true } } + + func unlock() async { + guard locked, !busy else { return } + busy = true; error = nil + let allowed = await evaluate(reason) + busy = false + if allowed { locked = false } else { error = String(localized: "Unlock to continue.") } + } + + nonisolated static func availableKind() -> Kind { + let context = LAContext() + var failure: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &failure) else { return .unavailable } + switch context.biometryType { + case .faceID: return .faceID + case .touchID: return .touchID + case .opticID: return .opticID + default: return .passcode + } + } + + nonisolated static func deviceOwner(_ reason: String) async -> Bool { + let context = LAContext() + context.localizedCancelTitle = String(localized: "Cancel") + return (try? await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason)) ?? false + } +} + +struct AppLockView: View { + let lock: NativeAppLock + let signOut: () -> Void + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + BrandWordmark() + Image(systemName: lock.kind == .touchID ? "touchid" : lock.kind == .passcode ? "lock" : "faceid") + .font(.largeTitle).foregroundStyle(Color.swAccentInk).accessibilityHidden(true) + Text("Your account is locked").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) + Text("Unlock to continue.").foregroundStyle(Color.swMuted) + if let error = lock.error, error != String(localized: "Unlock to continue.") { SWError(message: error) } + Button(lock.busy ? "Unlocking…" : "Unlock") { Task { await lock.unlock() } } + .buttonStyle(SWPrimary()).disabled(lock.busy).accessibilityIdentifier("app-unlock") + Button("Sign out", role: .destructive, action: signOut).frame(minHeight: 48).disabled(lock.busy) + }.padding(24).frame(maxWidth: 560).frame(maxWidth: .infinity) + }.background(Color.swBg).accessibilityIdentifier("app-lock") + } +} diff --git a/SpaceWorks/Resources/Localizable.xcstrings b/SpaceWorks/Resources/Localizable.xcstrings index 65104e1..38a8e20 100644 --- a/SpaceWorks/Resources/Localizable.xcstrings +++ b/SpaceWorks/Resources/Localizable.xcstrings @@ -452,6 +452,17 @@ } } }, + "App lock was not turned on.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "App lock was not turned on." + } + } + } + }, "Apple": { "extractionState": "manual", "localizations": { @@ -5886,6 +5897,50 @@ } } }, + "Require Face ID": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Require Face ID" + } + } + } + }, + "Require Optic ID": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Require Optic ID" + } + } + } + }, + "Require Touch ID": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Require Touch ID" + } + } + } + }, + "Require device passcode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Require device passcode" + } + } + } + }, "Reset helpful tips": { "extractionState": "manual", "localizations": { @@ -7932,6 +7987,28 @@ } } }, + "Unlock to continue.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unlock to continue." + } + } + } + }, + "Unlock your makerspace account.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unlock your makerspace account." + } + } + } + }, "Unpaid": { "extractionState": "manual", "localizations": { @@ -8636,6 +8713,17 @@ } } }, + "Your account is locked": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your account is locked" + } + } + } + }, "Your account requires two-step sign-in.": { "extractionState": "manual", "localizations": { diff --git a/SpaceWorks/Resources/en.lproj/InfoPlist.strings b/SpaceWorks/Resources/en.lproj/InfoPlist.strings index f3549fb..f93de8c 100644 --- a/SpaceWorks/Resources/en.lproj/InfoPlist.strings +++ b/SpaceWorks/Resources/en.lproj/InfoPlist.strings @@ -1 +1,2 @@ "NSCameraUsageDescription" = "Scan member and event codes or photograph handover evidence when you choose to."; +"NSFaceIDUsageDescription" = "Unlock your makerspace account when you turn on app lock."; diff --git a/SpaceWorks/RootView.swift b/SpaceWorks/RootView.swift index 8db7f9e..0fd5de9 100644 --- a/SpaceWorks/RootView.swift +++ b/SpaceWorks/RootView.swift @@ -346,6 +346,11 @@ private struct MemberScreen: View { if let notifications = auth.notifications { NavigationLink("Notifications") { NotificationSettingsView(state: notifications) }.frame(minHeight: 48).accessibilityIdentifier("notifications-entry") } + if let lock = auth.appLock, lock.available { + Toggle(lock.title, isOn: Binding(get: { lock.enabled }, set: { value in Task { await lock.setEnabled(value) } })) + .frame(minHeight: 48).disabled(lock.busy).accessibilityIdentifier("app-lock-toggle") + if let error = lock.error, !lock.locked { SWError(message: error) } + } Button("Show introduction") { replayIntro = true }.frame(minHeight: 48) NavigationLink("App guide") { AppGuideView(staffAvailable: staff != nil, serverLabel: state.connectedOrigin?.url.absoluteString, replayTips: member.replayGuidance) diff --git a/SpaceWorksTests/NativeAppLockTests.swift b/SpaceWorksTests/NativeAppLockTests.swift new file mode 100644 index 0000000..bea079e --- /dev/null +++ b/SpaceWorksTests/NativeAppLockTests.swift @@ -0,0 +1,39 @@ +import XCTest +@testable import SpaceWorks + +@MainActor final class NativeAppLockTests: XCTestCase { + private func settings() -> UserDefaults { UserDefaults(suiteName: UUID().uuidString)! } + + func testTurningTheLockOnRequiresAProofAndPersistsPerAccount() async { + let defaults = settings(); var allow = false; var prompts = 0 + let lock = NativeAppLock(scope: "https://spaceworks.example.12", settings: defaults, kind: .faceID) { _ in prompts += 1; return allow } + XCTAssertEqual(lock.title, "Require Face ID") + await lock.setEnabled(true) + XCTAssertFalse(lock.enabled); XCTAssertEqual(lock.error, "App lock was not turned on.") + allow = true; await lock.setEnabled(true) + XCTAssertTrue(lock.enabled); XCTAssertFalse(lock.locked); XCTAssertEqual(prompts, 2) + let relaunched = NativeAppLock(scope: "https://spaceworks.example.12", settings: defaults, kind: .faceID) { _ in true } + XCTAssertTrue(relaunched.enabled); XCTAssertTrue(relaunched.locked, "A cold launch with the lock on starts locked") + let otherAccount = NativeAppLock(scope: "https://spaceworks.example.13", settings: defaults, kind: .faceID) { _ in true } + XCTAssertFalse(otherAccount.enabled) + } + + func testBackgroundLocksAndOnlyASuccessfulProofUnlocks() async { + var allow = true + let lock = NativeAppLock(scope: "scope", settings: settings(), kind: .touchID) { _ in allow } + lock.lock(); XCTAssertFalse(lock.locked, "A disabled lock never locks") + await lock.setEnabled(true); lock.lock(); XCTAssertTrue(lock.locked) + allow = false; await lock.unlock() + XCTAssertTrue(lock.locked); XCTAssertEqual(lock.error, "Unlock to continue.") + allow = true; await lock.unlock() + XCTAssertFalse(lock.locked); XCTAssertNil(lock.error) + await lock.setEnabled(false); lock.lock(); XCTAssertFalse(lock.locked) + } + + func testUnavailableDeviceOwnerAuthenticationOffersNoLock() async { + let lock = NativeAppLock(scope: "scope", settings: settings(), kind: .unavailable) { _ in XCTFail("No prompt without a device owner method"); return true } + XCTAssertFalse(lock.available) + await lock.setEnabled(true) + XCTAssertFalse(lock.enabled) + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 074ff5a..e74a7cd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -41,6 +41,11 @@ read from the top level, so v2 taps previously fell back). Push, inbox and unive the backend `mobile_link_contract.py` destinations — shared-host `/m/{slug}/…` resolved through a fresh membership, and custom-domain forms scoped to the payload's makerspace. +**Native app lock.** Profile offers an opt-in "Require Face ID" (or Touch ID, Optic ID or device +passcode) lock per account using LocalAuthentication device-owner authentication. The app locks when +it backgrounds and on a cold launch, shows a lock screen with Unlock and Sign out, and never stores +biometric data. The Face ID purpose string is generated and checked in the Release audit. + **D6 hardening.** The privacy manifest declares no tracking and the account-linked data the app sends for app functionality. New copy is seeded in the English catalog. Documentation records the owner inputs below. diff --git a/scripts/audit_bundle.py b/scripts/audit_bundle.py index 819c494..e665301 100644 --- a/scripts/audit_bundle.py +++ b/scripts/audit_bundle.py @@ -26,6 +26,7 @@ def plist(path): check(info.get('CFBundleShortVersionString') and info.get('CFBundleVersion'), 'Missing version or build number') check('NSAppTransportSecurity' not in info, 'Release app contains transport security overrides') check(bool(info.get('NSCameraUsageDescription')), 'Missing camera purpose string') + check(bool(info.get('NSFaceIDUsageDescription')), 'Missing Face ID purpose string for app lock') schemes = {scheme for item in info.get('CFBundleURLTypes', []) for scheme in item.get('CFBundleURLSchemes', [])} check('spaceworks' in schemes, 'Missing app callback scheme') google = brand.get('google_reversed_client_id') diff --git a/scripts/generate.py b/scripts/generate.py index 5d5efb2..20ca673 100644 --- a/scripts/generate.py +++ b/scripts/generate.py @@ -96,6 +96,7 @@ def generate(raster=True): info["UISupportedInterfaceOrientations~ipad"] = info["UISupportedInterfaceOrientations"] + ["UIInterfaceOrientationPortraitUpsideDown"] info["UIAppFonts"] = ["Gilroy-Regular.ttf", "Gilroy-Medium.ttf", "Gilroy-SemiBold.ttf", "Gilroy-Bold.ttf", "Unbounded-Variable.ttf"] info["NSCameraUsageDescription"] = "Scan member and event codes or photograph handover evidence when you choose to." + info["NSFaceIDUsageDescription"] = "Unlock your makerspace account when you turn on app lock." schemes = ["spaceworks"] google_scheme = brand.get("google_reversed_client_id") if google_scheme is not None: From 0a0486d6e192f176566bd05bac2585fc7de71297 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:03:03 +0530 Subject: [PATCH 94/98] test(d7): include the Face ID purpose in the audited bundle fixture Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- scripts/tests/test_audit_bundle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tests/test_audit_bundle.py b/scripts/tests/test_audit_bundle.py index ed89c59..5855ab4 100644 --- a/scripts/tests/test_audit_bundle.py +++ b/scripts/tests/test_audit_bundle.py @@ -18,7 +18,7 @@ def setUp(self): self.app = Path(self.directory.name) / 'SpaceWorks.app' self.app.mkdir() self.brand = {'ios_bundle_id': 'org.spaceworks.app', 'app_name': 'Space Works'} - self.info = {'CFBundleIdentifier': 'org.spaceworks.app', 'CFBundleDisplayName': 'Space Works', 'UIDeviceFamily': [1, 2], 'CFBundleShortVersionString': '0.1.0', 'CFBundleVersion': '1', 'NSCameraUsageDescription': 'Camera use', 'CFBundleURLTypes': [{'CFBundleURLSchemes': ['spaceworks']}], 'UISupportedInterfaceOrientations': ['UIInterfaceOrientationPortrait', 'UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight'], 'UISupportedInterfaceOrientations~ipad': ['UIInterfaceOrientationPortrait', 'UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight'], 'UIAppFonts': ['Gilroy-Regular.ttf', 'Gilroy-Medium.ttf', 'Gilroy-SemiBold.ttf', 'Gilroy-Bold.ttf', 'Unbounded-Variable.ttf'], 'CFBundleExecutable': 'SpaceWorks', 'CFBundleIcons': {'CFBundlePrimaryIcon': {'CFBundleIconName': 'AppIcon'}}} + self.info = {'CFBundleIdentifier': 'org.spaceworks.app', 'CFBundleDisplayName': 'Space Works', 'UIDeviceFamily': [1, 2], 'CFBundleShortVersionString': '0.1.0', 'CFBundleVersion': '1', 'NSCameraUsageDescription': 'Camera use', 'NSFaceIDUsageDescription': 'App lock', 'CFBundleURLTypes': [{'CFBundleURLSchemes': ['spaceworks']}], 'UISupportedInterfaceOrientations': ['UIInterfaceOrientationPortrait', 'UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight'], 'UISupportedInterfaceOrientations~ipad': ['UIInterfaceOrientationPortrait', 'UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight'], 'UIAppFonts': ['Gilroy-Regular.ttf', 'Gilroy-Medium.ttf', 'Gilroy-SemiBold.ttf', 'Gilroy-Bold.ttf', 'Unbounded-Variable.ttf'], 'CFBundleExecutable': 'SpaceWorks', 'CFBundleIcons': {'CFBundlePrimaryIcon': {'CFBundleIconName': 'AppIcon'}}} self.info['UIApplicationSceneManifest'] = {'UIApplicationSupportsMultipleScenes': False} self.write_info() (self.app / 'PrivacyInfo.xcprivacy').write_bytes(plistlib.dumps({'NSPrivacyAccessedAPITypes': [{'NSPrivacyAccessedAPIType': 'NSPrivacyAccessedAPICategoryUserDefaults', 'NSPrivacyAccessedAPITypeReasons': ['CA92.1']}]})) From 83017c133c861c4af0d1ca6e37afb2464f5fdf11 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:48:34 +0530 Subject: [PATCH 95/98] feat(d8): member usage, staff glance, SMS/WhatsApp consent and phone account restore Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- .../SpaceWorksAPI/InsightsOperations.swift | 29 + .../openapi-generator-config.yaml | 6 + SpaceWorks/AccountRestore.swift | 113 ++++ SpaceWorks/AuthState.swift | 15 + SpaceWorks/AuthView.swift | 6 + SpaceWorks/DebugAuthFixtures.swift | 6 + SpaceWorks/Insights.swift | 190 ++++++ SpaceWorks/InsightsViews.swift | 196 ++++++ SpaceWorks/MemberRoutes.swift | 3 +- SpaceWorks/MemberViews.swift | 4 +- SpaceWorks/Resources/Localizable.xcstrings | 572 ++++++++++++++++++ SpaceWorks/RootView.swift | 23 +- SpaceWorks/SpaceWorksApp.swift | 3 + SpaceWorks/StaffViews.swift | 2 + SpaceWorksTests/InsightsTests.swift | 116 ++++ SpaceWorksUITests/NativeAuthFlowTests.swift | 4 + 16 files changed, 1279 insertions(+), 9 deletions(-) create mode 100644 Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/InsightsOperations.swift create mode 100644 SpaceWorks/AccountRestore.swift create mode 100644 SpaceWorks/Insights.swift create mode 100644 SpaceWorks/InsightsViews.swift create mode 100644 SpaceWorksTests/InsightsTests.swift diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/InsightsOperations.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/InsightsOperations.swift new file mode 100644 index 0000000..95b8a41 --- /dev/null +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/InsightsOperations.swift @@ -0,0 +1,29 @@ +import Foundation +import OpenAPIRuntime + +/// Member usage, the staff dashboard and SMS/WhatsApp consent. Callers bind the makerspace header with +/// `withMakerspace`; account restore is anonymous and never returns a session. +extension SpaceWorksService { + public func memberUsage(makerspaceID: Int) async throws -> Components.Schemas.MemberUsage { + try await client.api_v1_member_makerspaces_my_usage_retrieve(.init(path: .init(makerspace_id: makerspaceID))).ok.body.json + } + public func staffDashboard(makerspaceID: Int) async throws -> Components.Schemas.Dashboard { + try await client.api_v1_admin_makerspace_dashboard_retrieve(.init(path: .init(makerspace_id: makerspaceID))).ok.body.json + } + public func messagingConsent(makerspaceID: Int) async throws -> Components.Schemas.MemberMessaging { + try await client.api_v1_me_messaging_retrieve(.init(query: .init(makerspace: makerspaceID))).ok.body.json + } + public func setMessagingConsent(makerspaceID: Int, channel: Components.Schemas.Channel06eEnum, + optIn: Bool) async throws -> Components.Schemas.MemberMessaging { + let input = Components.Schemas.ConsentWrite(makerspace: makerspaceID, channel: channel, action: optIn ? .opted_in : .opted_out) + return try await client.api_v1_me_messaging_consent_create(.init(body: .json(input))).ok.body.json + } + /// Phone restore of an archived account; the OTP came from phone login start with the same challenge. + public func restoreAccountByPhone(phone: String, code: String, newPassword: String, challenge: String, + attestationJSON: Data) async throws -> Components.Schemas.AccountRestoreResult { + let input = Components.Schemas.AccountRestorePhone(phone: phone, delivery: .init(value1: .device), + client_platform: .init(value1: .ios), challenge: challenge, code: code, + attestation: .init(value1: try DeviceAttestation.payload(attestationJSON)), new_password: newPassword) + return try await publicClient.api_v1_auth_account_restore_phone_create(.init(body: .json(input))).ok.body.json + } +} diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml index 2d6cc58..3fb3354 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml @@ -118,3 +118,9 @@ filter: - api_v1_auth_reauth_social_start_create - api_v1_auth_reauth_social_complete_create - api_v1_auth_reauth_social_factor_create + + - api_v1_member_makerspaces_my_usage_retrieve + - api_v1_admin_makerspace_dashboard_retrieve + - api_v1_me_messaging_retrieve + - api_v1_me_messaging_consent_create + - api_v1_auth_account_restore_phone_create diff --git a/SpaceWorks/AccountRestore.swift b/SpaceWorks/AccountRestore.swift new file mode 100644 index 0000000..b8eb421 --- /dev/null +++ b/SpaceWorks/AccountRestore.swift @@ -0,0 +1,113 @@ +import Foundation +import Observation +import SwiftUI +import SpaceWorksAPI + +/// Identity-only restore of a deleted phone account. It never returns a session: the person signs in +/// afresh, and memberships, sessions and notifications stay revoked until the makerspace restores them. +@MainActor @Observable +final class AccountRestoreState: Identifiable { + enum Step: Equatable { case number, code, restored } + let id = UUID() + var number = "" + var code = "" + var password = "" + var confirmation = "" + private(set) var step = Step.number + private(set) var busy = false + private(set) var error: String? + private(set) var resendAfter = Date.distantPast + private(set) var phone: String? + private var started: PhoneSignInState.Started? + private let begin: @MainActor (String) async throws -> PhoneSignInState.Started + private let restore: @MainActor (String, String, String, String) async throws -> Components.Schemas.AccountRestoreResult + private let now: () -> Date + + init(begin: @escaping @MainActor (String) async throws -> PhoneSignInState.Started, + restore: @escaping @MainActor (String, String, String, String) async throws -> Components.Schemas.AccountRestoreResult, + now: @escaping () -> Date = Date.init) { + self.begin = begin; self.restore = restore; self.now = now + } + + func send() async { + guard !busy, now() >= resendAfter else { return } + guard let phone = PhoneSignInState.normalized(step == .code ? (phone ?? number) : number) else { + error = String(localized: "Enter your phone number with its country code."); return + } + busy = true; error = nil + defer { busy = false } + do { + started = try await begin(phone) + self.phone = phone; code = ""; step = .code; resendAfter = now().addingTimeInterval(60) + } catch { + if !APIError.isCancellation(error) { self.error = APIError.display(error) } + if let deadline = APIError.retryDeadline(error) { resendAfter = deadline } + } + } + + func submit() async { + guard !busy, step == .code, let phone else { return } + let digits = code.filter { $0.isASCII && $0.isNumber } + guard digits.count == 6 else { error = String(localized: "Enter the six-digit code."); return } + guard !password.isEmpty, password == confirmation else { error = String(localized: "Enter matching new passwords."); return } + guard let started, now() < started.expires else { + self.started = nil; code = ""; resendAfter = .distantPast + error = String(localized: "Your code expired. Send a new code."); return + } + busy = true; error = nil + defer { busy = false } + do { + let result = try await restore(phone, digits, password, started.challenge) + password = ""; confirmation = ""; code = "" + if result.restored { step = .restored } else { error = result.detail } + } catch { + if !APIError.isCancellation(error) { self.error = APIError.display(error) } + } + } + + func changeNumber() { guard !busy else { return }; step = .number; code = ""; error = nil; started = nil } +} + +struct AccountRestoreView: View { + @Bindable var form: AccountRestoreState + @Environment(\.dismiss) private var dismiss + @State private var passwordFocused = false + var body: some View { + NavigationStack { + ScrollView { + SWCard { + Text("Restore a deleted account").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) + switch form.step { + case .number: + Text("Enter the phone number on the deleted account. If it can still be restored, we'll text a code.").foregroundStyle(Color.swMuted) + Text("Phone number").brandFont(.headline) + TextField("Phone number", text: $form.number, prompt: Text(verbatim: "+91 98765 43210").foregroundColor(Color.swMuted)) + .textContentType(.telephoneNumber).keyboardType(.phonePad) + .padding(16).background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)).accessibilityIdentifier("restore-phone") + if let error = form.error { SWError(message: error) } + Button(form.busy ? "Sending…" : "Send verification code") { Task { await form.send() } } + .buttonStyle(SWPrimary()).disabled(form.busy).accessibilityIdentifier("restore-send") + case .code: + Text("Enter the code we sent and choose a new password.").foregroundStyle(Color.swMuted) + Text("Verification code").brandFont(.headline) + TextField("Verification code", text: $form.code).textContentType(.oneTimeCode).keyboardType(.numberPad) + .padding(16).background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)).accessibilityIdentifier("restore-code") + PasswordControl(title: String(localized: "New password"), text: $form.password, newPassword: true, identifier: "restore-password", focus: $passwordFocused) + PasswordControl(title: String(localized: "Confirm password"), text: $form.confirmation, newPassword: true, identifier: "restore-confirm") + if let error = form.error { SWError(message: error) } + Button(form.busy ? "Restoring…" : "Restore account") { Task { await form.submit() } } + .buttonStyle(SWPrimary()).disabled(form.busy).accessibilityIdentifier("restore-submit") + Button("Change number") { form.changeNumber() }.frame(minHeight: 48).disabled(form.busy) + case .restored: + Label("Account restored", systemImage: "checkmark.circle").brandFont(.title2, weight: .bold) + Text("Sign in with your new password. Memberships, sessions and notifications stay off until your makerspace restores them.").foregroundStyle(Color.swMuted) + Button("Back to sign in") { dismiss() }.buttonStyle(SWPrimary()).accessibilityIdentifier("restore-done") + } + }.padding(24).frame(maxWidth: 560).frame(maxWidth: .infinity) + }.background(Color.swBg).scrollDismissesKeyboard(.interactively) + .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() }.frame(minHeight: 48).disabled(form.busy) } } + }.interactiveDismissDisabled(form.busy).onDisappear { form.password = ""; form.confirmation = "" } + } +} diff --git a/SpaceWorks/AuthState.swift b/SpaceWorks/AuthState.swift index c66d546..bdd2c41 100644 --- a/SpaceWorks/AuthState.swift +++ b/SpaceWorks/AuthState.swift @@ -234,6 +234,21 @@ final class AuthState { }) } + /// Anonymous phone restore of a deleted account: a phone login code bound to a fresh challenge. + func accountRestore() -> AccountRestoreState { + let service = service + let attest = attest + return AccountRestoreState(begin: { phone in + let challenge = try await service.challenge(appID: Brand.bundleID, development: NativeAttestor.development) + let started = PhoneSignInState.Started(challenge: challenge.challenge, expires: Date().addingTimeInterval(TimeInterval(challenge.expires_in))) + try await service.phoneSignInStart(phone: phone, challenge: challenge.challenge) + return started + }, restore: { phone, code, password, challenge in + let proof = try await attest(challenge) + return try await service.restoreAccountByPhone(phone: phone, code: code, newPassword: password, challenge: challenge, attestationJSON: proof) + }) + } + private func completePhoneSignIn(phone: String, code: String, challenge: String) async throws { guard !busy, stage == .signIn else { throw CancellationError() } let feedbackGeneration = identityGeneration diff --git a/SpaceWorks/AuthView.swift b/SpaceWorks/AuthView.swift index ea804b0..4645535 100644 --- a/SpaceWorks/AuthView.swift +++ b/SpaceWorks/AuthView.swift @@ -128,6 +128,7 @@ struct SignInView: View { @State private var showPassword = false @State private var passwordFocused = false @State private var accountForm: AccountAccessState? + @State private var restoreForm: AccountRestoreState? @Environment(\.dynamicTypeSize) private var typeSize var body: some View { GeometryReader { geometry in @@ -188,6 +189,10 @@ struct SignInView: View { } } }.disabled(auth.busy) + if auth.phoneEnabled { + Button("Restore a deleted account") { restoreForm = auth.accountRestore() } + .frame(minHeight: 48).disabled(auth.busy).accessibilityIdentifier("restore-account") + } if !state.locked { Button("Change server") { state.changeServer() }.frame(minHeight: 48).disabled(auth.busy) } }.frame(maxWidth: 460) if geometry.size.width >= 960 && geometry.size.height >= 600 && !typeSize.isAccessibilitySize { @@ -203,6 +208,7 @@ struct SignInView: View { }.background(Color.swBg).scrollDismissesKeyboard(.interactively).accessibilityIdentifier("sign-in-scroll") } .sheet(item: $accountForm) { form in AccountAccessView(form: form) { auth.username = form.email; accountForm = nil } } + .sheet(item: $restoreForm) { form in AccountRestoreView(form: form) } } private func field(_ title: String, @ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 8) { diff --git a/SpaceWorks/DebugAuthFixtures.swift b/SpaceWorks/DebugAuthFixtures.swift index cdc5f7d..0a2df4a 100644 --- a/SpaceWorks/DebugAuthFixtures.swift +++ b/SpaceWorks/DebugAuthFixtures.swift @@ -23,6 +23,12 @@ enum DebugAuthFixtures { let value = minimum.map { "\"\($0)\"" } ?? "null" return #"{"email_enabled":false,"public_image_max_bytes":10485760,"password_login":{"member":true},"native":{"min_app_version":{"android":null,"ios":"# + value + #"},"providers":{"password":true,"phone":true,"google":null,"apple":null,"oidc":[]}}}"# } + static let chartSpec = #"{"series":["issued_count"],"x":{"field":"period","kind":"time"},"values":[{"field":"issued_count","unit":"loans"}],"currency_field":null,"partition":null,"negatives":"clip","truncation":null,"alt_template":"Loans by month"}"# + static var usage: String { + #"{"usage_available":true,"loans":{"count":3,"returned_count":2,"currently_out":1,"monthly":{"rows":[{"period":"2026-09","issued_count":2,"return_events":1},{"period":"2026-10","issued_count":1,"return_events":1}],"chart_spec":"# + chartSpec + #"}},"payments":{"paid_by_currency":{"inr":"1250.00"},"outstanding_by_currency":{"inr":"250.00"},"totals":{"rows":[],"chart_spec":"# + chartSpec + #"}}}"# + } + static let dashboard = #"{"scope_mode":"full","pending_requests":2,"awaiting_issue":0,"queue_tiles":{"ready_to_issue":1,"due_returns":3,"pickups":0},"widgets":[],"outstanding_by_currency":{"inr":"250.00"}}"# + static let messaging = #"{"sms":{"available":true,"consented":false,"phone_verified":false,"phone_masked":"","suppressed":false},"whatsapp":{"available":false,"consented":false,"phone_verified":false,"phone_masked":"","suppressed":false}}"# static let enrollment = #"{"secret":"TESTAUTHENTICATORKEY","provisioning_uri":"otpauth://totp/SpaceWorks:maker?secret=TESTAUTHENTICATORKEY&issuer=SpaceWorks","qr_data_url":""}"# } #endif diff --git a/SpaceWorks/Insights.swift b/SpaceWorks/Insights.swift new file mode 100644 index 0000000..3c9afe9 --- /dev/null +++ b/SpaceWorks/Insights.swift @@ -0,0 +1,190 @@ +import Foundation +import Observation +import OpenAPIRuntime +import SpaceWorksAPI + +/// One labelled point of a server series; `value` is the server's number as text, never recomputed. +struct UsagePoint: Equatable, Sendable { let label: String; let value: String; let amount: Double } +/// A server series reduced to its declared x field and first measure. No points means table-only. +struct UsageSeries: Equatable, Sendable { + let points: [UsagePoint] + let unit: String + let summary: String + static func from(_ series: Components.Schemas.MemberUsageSeries) -> UsageSeries { + let x = series.chart_spec.x.field + guard let measure = series.chart_spec.values.first else { return .init(points: [], unit: "", summary: series.chart_spec.alt_template) } + let points: [UsagePoint] = series.rows.compactMap { row in + guard let label = text(row.additionalProperties[x]), let raw = text(row.additionalProperties[measure.field]), + let amount = Double(raw) else { return nil } + return UsagePoint(label: label, value: raw, amount: amount) + } + return .init(points: points, unit: measure.unit, summary: series.chart_spec.alt_template) + } + static func text(_ value: OpenAPIValueContainer?) -> String? { + switch value?.value { + case let text as String: return text + case let number as Int: return String(number) + case let number as Double: return number.rounded() == number && abs(number) < 1e15 ? String(Int(number)) : String(number) + default: return nil + } + } +} + +struct StaffCounter: Equatable, Sendable { let key: String; let value: Int } +struct StaffWidget: Equatable, Sendable { let key: String; let title: String; let headline: String? } +struct StaffGlance: Equatable, Sendable { + let handover: Bool + let counters: [StaffCounter] + let queue: [StaffCounter] + let widgets: [StaffWidget] + let outstanding: [(currency: String, amount: String)] + static func == (lhs: StaffGlance, rhs: StaffGlance) -> Bool { + lhs.handover == rhs.handover && lhs.counters == rhs.counters && lhs.queue == rhs.queue && lhs.widgets == rhs.widgets + && lhs.outstanding.map(\.currency) == rhs.outstanding.map(\.currency) && lhs.outstanding.map(\.amount) == rhs.outstanding.map(\.amount) + } + /// Omitted counters are unavailable to this actor; only server-reported non-zero counters are shown. + static func from(_ value: Components.Schemas.Dashboard) -> StaffGlance { + let counters: [(String, Int?)] = [ + ("pending_requests", value.pending_requests), ("awaiting_issue", value.awaiting_issue), ("overdue_loans", value.overdue_loans), + ("pending_bookings", value.pending_bookings), ("upcoming_bookings", value.upcoming_bookings), ("upcoming_events", value.upcoming_events), + ("pending_event_registrations", value.pending_event_registrations), ("open_problem_reports", value.open_problem_reports), + ("low_stock", value.low_stock), ("pending_prints", value.pending_prints), ("active_prints", value.active_prints), + ("prints_awaiting_collection", value.prints_awaiting_collection), ("maintenance_overdue", value.maintenance_overdue), + ("warranty_expiring", value.warranty_expiring), ("stocktakes_awaiting_approval", value.stocktakes_awaiting_approval), + ("pending_payments", value.pending_payments), ("failed_emails", value.failed_emails)] + let queue = value.queue_tiles.map { tiles in + [("ready_to_issue", tiles.ready_to_issue), ("due_returns", tiles.due_returns), ("pickups", tiles.pickups)] + .compactMap { key, count in count.map { StaffCounter(key: key, value: $0) } } + } ?? [] + let handover = value.scope_mode == .handover + let widgets = handover ? [] : (value.widgets ?? []).map { widget in + StaffWidget(key: widget.key, title: widget.title, + headline: ["count", "total", "value"].lazy.compactMap { UsageSeries.text(widget.data.additionalProperties[$0]) }.first { Double($0) != nil }) + } + return StaffGlance(handover: handover, + counters: counters.compactMap { key, count in count.flatMap { $0 > 0 ? StaffCounter(key: key, value: $0) : nil } }, + queue: queue, widgets: widgets, + outstanding: (value.outstanding_by_currency?.additionalProperties ?? [:]).sorted { $0.key < $1.key }.map { ($0.key.uppercased(), $0.value) }) + } + static func label(_ key: String) -> String { + switch key { + case "pending_requests": return String(localized: "Pending requests") + case "awaiting_issue": return String(localized: "Awaiting issue") + case "overdue_loans": return String(localized: "Overdue loans") + case "pending_bookings": return String(localized: "Pending bookings") + case "upcoming_bookings": return String(localized: "Upcoming bookings") + case "upcoming_events": return String(localized: "Upcoming events") + case "pending_event_registrations": return String(localized: "Pending event registrations") + case "open_problem_reports": return String(localized: "Open problem reports") + case "low_stock": return String(localized: "Low stock") + case "pending_prints": return String(localized: "Pending prints") + case "active_prints": return String(localized: "Active prints") + case "prints_awaiting_collection": return String(localized: "Prints awaiting collection") + case "maintenance_overdue": return String(localized: "Maintenance overdue") + case "warranty_expiring": return String(localized: "Warranty expiring") + case "stocktakes_awaiting_approval": return String(localized: "Stocktakes awaiting approval") + case "pending_payments": return String(localized: "Pending payments") + case "failed_emails": return String(localized: "Failed emails") + case "ready_to_issue": return String(localized: "Ready to issue") + case "due_returns": return String(localized: "Due back") + case "pickups": return String(localized: "Pickups") + default: return key + } + } +} + +protocol InsightsRepository: Sendable { + func usage() async throws -> Components.Schemas.MemberUsage + func dashboard() async throws -> Components.Schemas.Dashboard + func messaging() async throws -> Components.Schemas.MemberMessaging + func consent(channel: Components.Schemas.Channel06eEnum, optIn: Bool) async throws -> Components.Schemas.MemberMessaging +} + +struct LiveInsightsRepository: InsightsRepository { + let service: SpaceWorksService + let expectedCredentials: CredentialSnapshot? + let makerspaceID: Int + var onUnauthorized: @Sendable (String) async -> Void = { _ in } + var onForbidden: @Sendable () async -> Void = {} + private func checked(_ work: @Sendable () async throws -> T) async throws -> T { + guard let expectedCredentials, await service.refresh.isSameInstallation(expectedCredentials) else { throw CancellationError() } + do { return try await service.withMakerspace(makerspaceID, expectedCredentials: expectedCredentials, operation: work) } + catch { + if Task.isCancelled || APIError.isCancellation(error) { throw error } + guard await service.refresh.isSameInstallation(expectedCredentials) else { throw CancellationError() } + let empty = await service.refresh.current() == nil + if APIError.status(error) == 401 || empty { await onUnauthorized(APIError.display(error)) } + else if APIError.status(error) == 403 { await onForbidden() } + throw error + } + } + func usage() async throws -> Components.Schemas.MemberUsage { try await checked { try await service.memberUsage(makerspaceID: makerspaceID) } } + func dashboard() async throws -> Components.Schemas.Dashboard { try await checked { try await service.staffDashboard(makerspaceID: makerspaceID) } } + func messaging() async throws -> Components.Schemas.MemberMessaging { try await checked { try await service.messagingConsent(makerspaceID: makerspaceID) } } + func consent(channel: Components.Schemas.Channel06eEnum, optIn: Bool) async throws -> Components.Schemas.MemberMessaging { + try await checked { try await service.setMessagingConsent(makerspaceID: makerspaceID, channel: channel, optIn: optIn) } + } +} + +/// Per-session, per-makerspace insight state. The owning workspace invalidates it on switch or sign-out. +@MainActor @Observable +final class InsightsState { + enum Load: Equatable { case idle, loading, ready(Value), failed(String) } + private(set) var usage = Load.idle + private(set) var dashboard = Load.idle + private(set) var messaging = Load.idle + private(set) var consentBusy: Components.Schemas.Channel06eEnum? + private(set) var consentError: String? + private var active = true + private var retryAfter = Date.distantPast + private let repository: any InsightsRepository + private let now: () -> Date + init(repository: any InsightsRepository, now: @escaping () -> Date = Date.init) { self.repository = repository; self.now = now } + + func refreshUsage() async { + guard active, usage != .loading, now() >= retryAfter else { return } + usage = .loading + do { let value = try await repository.usage(); if active { usage = .ready(value) } } + catch { if active { usage = failed(error) ?? .idle } } + } + func refreshDashboard() async { + guard active, dashboard != .loading, now() >= retryAfter else { return } + dashboard = .loading + do { let value = try await repository.dashboard(); if active { dashboard = .ready(.from(value)) } } + catch { if active { dashboard = failed(error) ?? .idle } } + } + func refreshMessaging() async { + guard active, messaging != .loading, now() >= retryAfter else { return } + messaging = .loading + do { let value = try await repository.messaging(); if active { messaging = .ready(value) } } + catch { if active { messaging = failed(error) ?? .idle } } + } + static func canOptIn(_ channel: Components.Schemas.MessagingChannel) -> Bool { + channel.available && channel.phone_verified && !channel.suppressed && !channel.consented + } + /// The server decides consent; the switch shows its returned state, and any refusal refetches it. + func setConsent(_ channel: Components.Schemas.Channel06eEnum, optIn: Bool) async { + guard active, consentBusy == nil, now() >= retryAfter, case .ready(let current) = messaging else { return } + let target = channel == .sms ? current.sms : current.whatsapp + if optIn && !Self.canOptIn(target) { return } + consentBusy = channel; consentError = nil + defer { consentBusy = nil } + do { + let value = try await repository.consent(channel: channel, optIn: optIn) + if active { messaging = .ready(value) } + } catch { + guard active, !APIError.isCancellation(error) else { return } + consentError = APIError.display(error) + if let deadline = APIError.retryDeadline(error) { retryAfter = deadline } + consentBusy = nil + messaging = .idle + await refreshMessaging() + } + } + func invalidate() { active = false; usage = .idle; dashboard = .idle; messaging = .idle; consentError = nil } + private func failed(_ error: Error) -> Load? { + guard !APIError.isCancellation(error) else { return nil } + if let deadline = APIError.retryDeadline(error) { retryAfter = deadline } + return .failed(APIError.display(error)) + } +} diff --git a/SpaceWorks/InsightsViews.swift b/SpaceWorks/InsightsViews.swift new file mode 100644 index 0000000..85d2891 --- /dev/null +++ b/SpaceWorks/InsightsViews.swift @@ -0,0 +1,196 @@ +import SwiftUI +import SpaceWorksAPI + +/// A server number with its label. Numbers use tabular digits in accent ink. +struct StatTile: View { + let label: String + let value: String + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(verbatim: value).brandFont(.title2, weight: .semibold).monospacedDigit().foregroundStyle(Color.swAccentInk) + Text(label).brandFont(.subheadline).foregroundStyle(Color.swMuted) + }.frame(maxWidth: .infinity, minHeight: 64, alignment: .leading).padding(12) + .background(Color.swPanel, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.swLine)) + .accessibilityElement(children: .combine) + } +} + +/// Horizontal bars for one server series, each value written out; never an inferred aggregate. +struct UsageBars: View { + let title: String + let series: UsageSeries + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title).brandFont(.headline) + if series.points.isEmpty { + Text("No activity in this period yet.").foregroundStyle(Color.swMuted) + } else { + let top = max(series.points.map(\.amount).max() ?? 0, 1) + ForEach(Array(series.points.enumerated()), id: \.offset) { _, point in + VStack(alignment: .leading, spacing: 4) { + HStack { Text(verbatim: point.label); Spacer(); Text(verbatim: [point.value, series.unit].filter { !$0.isEmpty }.joined(separator: " ")).monospacedDigit() } + .brandFont(.subheadline) + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule().fill(Color.swAccentSoft) + Capsule().fill(Color.swAccentInk).frame(width: geometry.size.width * max(0, min(1, point.amount / top))) + } + }.frame(height: 8).accessibilityHidden(true) + }.accessibilityElement(children: .combine) + } + } + } + } +} + +struct UsageSection: View { + let state: InsightsState + @Environment(\.dynamicTypeSize) private var textSize + private var columns: [GridItem] { textSize.isAccessibilitySize ? [GridItem(.flexible())] : [GridItem(.adaptive(minimum: 140), spacing: 12)] } + var body: some View { + SWCard { + Text("Your activity").brandFont(.title2, weight: .bold).accessibilityAddTraits(.isHeader) + switch state.usage { + case .idle, .loading: ProgressView("Loading activity").frame(minHeight: 48) + case .failed(let message): + SWError(message: message) + Button("Refresh activity") { Task { await state.refreshUsage() } }.frame(minHeight: 48) + case .ready(let usage): + if !usage.usage_available && usage.payments == nil { + Text("Activity details aren't available for this makerspace.").foregroundStyle(Color.swMuted) + } + if let loans = usage.loans { + Text("Loans").brandFont(.headline) + LazyVGrid(columns: columns, alignment: .leading, spacing: 12) { + StatTile(label: String(localized: "Borrowed"), value: "\(loans.count)") + StatTile(label: String(localized: "Returned"), value: "\(loans.returned_count)") + StatTile(label: String(localized: "Out now"), value: "\(loans.currently_out)") + } + UsageBars(title: String(localized: "By month"), series: .from(loans.monthly)) + } + if let machines = usage.machines { + Text("Machine time").brandFont(.headline) + StatTile(label: String(localized: "Hours"), value: machines.hours) + UsageBars(title: String(localized: "By month"), series: .from(machines.monthly)) + UsageBars(title: String(localized: "Most used machines"), series: .from(machines.top_machines)) + } + if usage.bookings != nil || usage.events != nil { + LazyVGrid(columns: columns, alignment: .leading, spacing: 12) { + if let bookings = usage.bookings { StatTile(label: String(localized: "Bookings"), value: "\(bookings.count)") } + if let events = usage.events { StatTile(label: String(localized: "Events attended"), value: "\(events.attended_count)") } + } + if let bookings = usage.bookings { UsageBars(title: String(localized: "By month"), series: .from(bookings.monthly)) } + } + if let payments = usage.payments { + Text("Payments").brandFont(.headline) + let currencies = Set(payments.paid_by_currency.additionalProperties.keys).union(payments.outstanding_by_currency.additionalProperties.keys).sorted() + LazyVGrid(columns: columns, alignment: .leading, spacing: 12) { + ForEach(currencies, id: \.self) { currency in + if let paid = payments.paid_by_currency.additionalProperties[currency] { + StatTile(label: String(localized: "Paid"), value: currency.uppercased() + " " + paid) + } + if let outstanding = payments.outstanding_by_currency.additionalProperties[currency] { + StatTile(label: String(localized: "Outstanding"), value: currency.uppercased() + " " + outstanding) + } + } + } + } + } + }.accessibilityIdentifier("member-usage") + .task { if state.usage == .idle { await state.refreshUsage() } } + } +} + +struct StaffGlanceSection: View { + let state: InsightsState + @Environment(\.dynamicTypeSize) private var textSize + private var columns: [GridItem] { textSize.isAccessibilitySize ? [GridItem(.flexible())] : [GridItem(.adaptive(minimum: 160), spacing: 12)] } + var body: some View { + SWCard { + Text("Today at a glance").brandFont(.title2, weight: .bold).accessibilityAddTraits(.isHeader) + switch state.dashboard { + case .idle, .loading: ProgressView().frame(minHeight: 48) + case .failed(let message): + SWError(message: message) + Button("Refresh dashboard") { Task { await state.refreshDashboard() } }.frame(minHeight: 48) + case .ready(let glance): + if glance.counters.isEmpty && glance.queue.isEmpty && glance.widgets.isEmpty && glance.outstanding.isEmpty { + Text("Nothing needs your attention right now.").foregroundStyle(Color.swMuted) + } + if !glance.queue.isEmpty { + Text("Handover queue").brandFont(.headline) + LazyVGrid(columns: columns, alignment: .leading, spacing: 12) { + ForEach(glance.queue, id: \.key) { StatTile(label: StaffGlance.label($0.key), value: "\($0.value)") } + } + } + if !glance.counters.isEmpty { + LazyVGrid(columns: columns, alignment: .leading, spacing: 12) { + ForEach(glance.counters, id: \.key) { StatTile(label: StaffGlance.label($0.key), value: "\($0.value)") } + } + } + if !glance.outstanding.isEmpty { + Text("Outstanding payments").brandFont(.headline) + LazyVGrid(columns: columns, alignment: .leading, spacing: 12) { + ForEach(glance.outstanding, id: \.currency) { StatTile(label: String(localized: "Outstanding"), value: $0.currency + " " + $0.amount) } + } + } + if !glance.widgets.isEmpty { + Text("Reports").brandFont(.headline) + LazyVGrid(columns: columns, alignment: .leading, spacing: 12) { + ForEach(glance.widgets, id: \.key) { widget in + if let headline = widget.headline { StatTile(label: widget.title, value: headline) } + else { Text(verbatim: widget.title).frame(maxWidth: .infinity, minHeight: 48, alignment: .leading) } + } + } + } + } + }.accessibilityIdentifier("staff-glance") + .task { if state.dashboard == .idle { await state.refreshDashboard() } } + } +} + +struct MessagingConsentView: View { + let state: InsightsState + var openAccount: (() -> Void)? = nil + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + Text("Text and WhatsApp messages").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) + Text("Choose whether this makerspace can reach you by text message or WhatsApp. App notifications and email are set separately.") + .foregroundStyle(Color.swMuted) + if let error = state.consentError { SWError(message: error) } + switch state.messaging { + case .idle, .loading: ProgressView().frame(minHeight: 48) + case .failed(let message): + SWError(message: message) + Button("Try again") { Task { await state.refreshMessaging() } }.frame(minHeight: 48) + case .ready(let consent): + channel(.sms, title: String(localized: "Text messages (SMS)"), value: consent.sms) + channel(.whatsapp, title: String(localized: "WhatsApp"), value: consent.whatsapp) + } + }.padding(24).frame(maxWidth: 680).frame(maxWidth: .infinity) + }.background(Color.swBg).navigationTitle("Messages").navigationBarTitleDisplayMode(.inline) + .task { await state.refreshMessaging() } + .accessibilityIdentifier("messaging-consent") + } + private func channel(_ key: Components.Schemas.Channel06eEnum, title: String, value: Components.Schemas.MessagingChannel) -> some View { + SWCard { + Toggle(isOn: Binding(get: { value.consented }, set: { optIn in Task { await state.setConsent(key, optIn: optIn) } })) { + VStack(alignment: .leading, spacing: 4) { + Text(verbatim: title).brandFont(.headline) + Text(value.consented ? "On" : "Off").brandFont(.subheadline).foregroundStyle(Color.swMuted) + } + }.frame(minHeight: 48).disabled(state.consentBusy != nil || (!value.consented && !InsightsState.canOptIn(value))) + .accessibilityIdentifier("messaging-\(key.rawValue)") + if !value.available { Text("This makerspace doesn't send these messages.").foregroundStyle(Color.swMuted) } + else if value.suppressed { Text("Messages to this number are paused after a STOP reply. Contact your makerspace to resume them.").foregroundStyle(Color.swMuted) } + else if !value.phone_verified { + Text("Verify your phone number in Account and security before turning this on.").foregroundStyle(Color.swMuted) + if let openAccount { Button("Open Account and security", action: openAccount).frame(minHeight: 48) } + } else if !value.phone_masked.isEmpty { + Text("Messages go to \(value.phone_masked).").foregroundStyle(Color.swMuted) + } + } + } +} diff --git a/SpaceWorks/MemberRoutes.swift b/SpaceWorks/MemberRoutes.swift index 4311bc0..af2ecc8 100644 --- a/SpaceWorks/MemberRoutes.swift +++ b/SpaceWorks/MemberRoutes.swift @@ -14,6 +14,7 @@ struct MemberRouteView: View { var payments: PaymentsState? = nil var staff: StaffState? = nil var account: AccountState? = nil + var insights: InsightsState? = nil #if DEBUG var onDemoDelete: (() -> Void)? = nil var onDemoFix: ((DemoDeletionState.Blocker) -> Void)? = nil @@ -43,7 +44,7 @@ struct MemberRouteView: View { #endif } else { disabledFeature("Sign in again to manage this account.") } case .staff: - if let staff { StaffView(state: staff) } + if let staff { StaffView(state: staff, insights: insights) } else { disabledFeature("Staff tools require an authorized native staff session.") } case .payments: if let payments { PaymentsView(state: payments) } diff --git a/SpaceWorks/MemberViews.swift b/SpaceWorks/MemberViews.swift index c7166c7..c3204ba 100644 --- a/SpaceWorks/MemberViews.swift +++ b/SpaceWorks/MemberViews.swift @@ -14,6 +14,7 @@ struct MemberContent: View { var paymentsAvailable = false var guidanceBookingsEnabled = true var openLink: ((String) -> Void)? = nil + var insights: InsightsState? = nil @State private var showCard = false @State private var renewal: Components.Schemas.MemberLoanActivity? @Environment(\.dynamicTypeSize) private var textSize @@ -61,8 +62,9 @@ struct MemberContent: View { notices } } + if tab == .home, let insights { UsageSection(state: insights) } }.padding(24).frame(maxWidth: 1040, alignment: .leading).frame(maxWidth: .infinity) - }.refreshable { await state.loadHome() } + }.refreshable { await state.loadHome(); if tab == .home { await insights?.refreshUsage() } } } }.background(Color.swBg) .task(id: tab) { diff --git a/SpaceWorks/Resources/Localizable.xcstrings b/SpaceWorks/Resources/Localizable.xcstrings index 38a8e20..75cab2f 100644 --- a/SpaceWorks/Resources/Localizable.xcstrings +++ b/SpaceWorks/Resources/Localizable.xcstrings @@ -210,6 +210,17 @@ } } }, + "Account restored": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account restored" + } + } + } + }, "Action preview result": { "extractionState": "manual", "localizations": { @@ -243,6 +254,28 @@ } } }, + "Active prints": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Active prints" + } + } + } + }, + "Activity details aren't available for this makerspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Activity details aren't available for this makerspace." + } + } + } + }, "Add a reason before rejecting this request.": { "extractionState": "manual", "localizations": { @@ -628,6 +661,17 @@ } } }, + "Awaiting issue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Awaiting issue" + } + } + } + }, "Back": { "extractionState": "manual", "localizations": { @@ -793,6 +837,17 @@ } } }, + "Borrowed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Borrowed" + } + } + } + }, "Borrower": { "extractionState": "manual", "localizations": { @@ -837,6 +892,17 @@ } } }, + "By month": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "By month" + } + } + } + }, "Calling code": { "extractionState": "manual", "localizations": { @@ -1519,6 +1585,17 @@ } } }, + "Choose whether this makerspace can reach you by text message or WhatsApp. App notifications and email are set separately.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose whether this makerspace can reach you by text message or WhatsApp. App notifications and email are set separately." + } + } + } + }, "Choose which updates you receive in Communication preferences. Required safety messages remain available in your inbox.": { "extractionState": "manual", "localizations": { @@ -2531,6 +2608,17 @@ } } }, + "Due back": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Due back" + } + } + } + }, "Due date": { "extractionState": "manual", "localizations": { @@ -2762,6 +2850,39 @@ } } }, + "Enter matching new passwords.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter matching new passwords." + } + } + } + }, + "Enter the code we sent and choose a new password.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter the code we sent and choose a new password." + } + } + } + }, + "Enter the phone number on the deleted account. If it can still be restored, we'll text a code.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter the phone number on the deleted account. If it can still be restored, we'll text a code." + } + } + } + }, "Enter the server address provided by your makerspace.": { "extractionState": "manual", "localizations": { @@ -3015,6 +3136,17 @@ } } }, + "Events attended": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Events attended" + } + } + } + }, "Evidence photo": { "extractionState": "manual", "localizations": { @@ -3103,6 +3235,17 @@ } } }, + "Failed emails": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed emails" + } + } + } + }, "File access was refused. Request a new link and try again.": { "extractionState": "manual", "localizations": { @@ -3235,6 +3378,17 @@ } } }, + "Handover queue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Handover queue" + } + } + } + }, "Hardware requests": { "extractionState": "manual", "localizations": { @@ -3268,6 +3422,17 @@ } } }, + "Hours": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hours" + } + } + } + }, "I checked the borrower against this request": { "extractionState": "manual", "localizations": { @@ -3862,6 +4027,17 @@ } } }, + "Loading activity": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading activity" + } + } + } + }, "Loading booking…": { "extractionState": "manual", "localizations": { @@ -3961,6 +4137,50 @@ } } }, + "Loans": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loans" + } + } + } + }, + "Low stock": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Low stock" + } + } + } + }, + "Machine time": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Machine time" + } + } + } + }, + "Maintenance overdue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Maintenance overdue" + } + } + } + }, "Make room for your next idea. Availability and approvals come from your makerspace.": { "extractionState": "manual", "localizations": { @@ -4115,6 +4335,17 @@ } } }, + "Messages to this number are paused after a STOP reply. Contact your makerspace to resume them.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Messages to this number are paused after a STOP reply. Contact your makerspace to resume them." + } + } + } + }, "Missing": { "extractionState": "manual", "localizations": { @@ -4159,6 +4390,17 @@ } } }, + "Most used machines": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Most used machines" + } + } + } + }, "Name": { "extractionState": "manual", "localizations": { @@ -4236,6 +4478,17 @@ } } }, + "No activity in this period yet.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No activity in this period yet." + } + } + } + }, "No confirmed bookings are shown in this window.": { "extractionState": "manual", "localizations": { @@ -4335,6 +4588,17 @@ } } }, + "Nothing needs your attention right now.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nothing needs your attention right now." + } + } + } + }, "Notices": { "extractionState": "manual", "localizations": { @@ -4456,6 +4720,17 @@ } } }, + "Open Account and security": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Account and security" + } + } + } + }, "Open Inventory, search or filter, then choose an item. Add the items you need and review quantities before submitting your request. Staff confirm availability, collection and borrowing terms.": { "extractionState": "manual", "localizations": { @@ -4555,6 +4830,17 @@ } } }, + "Open problem reports": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open problem reports" + } + } + } + }, "Open sample updates from your makerspaces. Changes stay in this preview.": { "extractionState": "manual", "localizations": { @@ -4643,6 +4929,39 @@ } } }, + "Out now": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Out now" + } + } + } + }, + "Outstanding": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Outstanding" + } + } + } + }, + "Outstanding payments": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Outstanding payments" + } + } + } + }, "Overdue": { "extractionState": "manual", "localizations": { @@ -4929,6 +5248,28 @@ } } }, + "Pending bookings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pending bookings" + } + } + } + }, + "Pending event registrations": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pending event registrations" + } + } + } + }, "Pending payment": { "extractionState": "manual", "localizations": { @@ -4940,6 +5281,39 @@ } } }, + "Pending payments": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pending payments" + } + } + } + }, + "Pending prints": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pending prints" + } + } + } + }, + "Pending requests": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pending requests" + } + } + } + }, "Pending review": { "extractionState": "manual", "localizations": { @@ -5050,6 +5424,17 @@ } } }, + "Pickups": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pickups" + } + } + } + }, "Please sign in again.": { "extractionState": "manual", "localizations": { @@ -5259,6 +5644,17 @@ } } }, + "Prints awaiting collection": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Prints awaiting collection" + } + } + } + }, "Privacy and help": { "extractionState": "manual", "localizations": { @@ -5369,6 +5765,17 @@ } } }, + "Ready to issue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ready to issue" + } + } + } + }, "Reason": { "extractionState": "manual", "localizations": { @@ -5435,6 +5842,28 @@ } } }, + "Refresh activity": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh activity" + } + } + } + }, + "Refresh dashboard": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh dashboard" + } + } + } + }, "Refresh payment status": { "extractionState": "manual", "localizations": { @@ -5743,6 +6172,17 @@ } } }, + "Reports": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reports" + } + } + } + }, "Request an export of your personal data held by this makerspace. Keep downloaded files somewhere private.": { "extractionState": "manual", "localizations": { @@ -5996,6 +6436,17 @@ } } }, + "Restore a deleted account": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restore a deleted account" + } + } + } + }, "Retry account options": { "extractionState": "manual", "localizations": { @@ -6645,6 +7096,17 @@ } } }, + "Sign in with your new password. Memberships, sessions and notifications stay off until your makerspace restores them.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in with your new password. Memberships, sessions and notifications stay off until your makerspace restores them." + } + } + } + }, "Sign out": { "extractionState": "manual", "localizations": { @@ -6964,6 +7426,17 @@ } } }, + "Stocktakes awaiting approval": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stocktakes awaiting approval" + } + } + } + }, "Stop renewal": { "extractionState": "manual", "localizations": { @@ -7008,6 +7481,28 @@ } } }, + "Text and WhatsApp messages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Text and WhatsApp messages" + } + } + } + }, + "Text messages (SMS)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Text messages (SMS)" + } + } + } + }, "That code didn't work. Check it and try again.": { "extractionState": "manual", "localizations": { @@ -7514,6 +8009,17 @@ } } }, + "This makerspace doesn't send these messages.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This makerspace doesn't send these messages." + } + } + } + }, "This makerspace doesn't share booked times. Your request is checked when submitted.": { "extractionState": "manual", "localizations": { @@ -7811,6 +8317,17 @@ } } }, + "Today at a glance": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Today at a glance" + } + } + } + }, "Too many requests. Try again later.": { "extractionState": "manual", "localizations": { @@ -8075,6 +8592,17 @@ } } }, + "Upcoming events": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Upcoming events" + } + } + } + }, "Update password": { "extractionState": "manual", "localizations": { @@ -8482,6 +9010,17 @@ } } }, + "Verify your phone number in Account and security before turning this on.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verify your phone number in Account and security before turning this on." + } + } + } + }, "View availability": { "extractionState": "manual", "localizations": { @@ -8559,6 +9098,17 @@ } } }, + "Warranty expiring": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Warranty expiring" + } + } + } + }, "We sent a verification code to your phone.": { "extractionState": "manual", "localizations": { @@ -8614,6 +9164,17 @@ } } }, + "WhatsApp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "WhatsApp" + } + } + } + }, "Working…": { "extractionState": "manual", "localizations": { @@ -8768,6 +9329,17 @@ } } }, + "Your activity": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your activity" + } + } + } + }, "Your card QR code isn't available. Ask the front desk for help.": { "extractionState": "manual", "localizations": { diff --git a/SpaceWorks/RootView.swift b/SpaceWorks/RootView.swift index 0fd5de9..bc333c9 100644 --- a/SpaceWorks/RootView.swift +++ b/SpaceWorks/RootView.swift @@ -198,6 +198,7 @@ private struct MemberWorkspace: View { @State private var payments: PaymentsState @State private var account: AccountState? @State private var staff: StaffState? + @State private var insights: InsightsState let actions: LiveMemberActionsRepository @State private var columns = NavigationSplitViewVisibility.automatic @State private var preferredCompactColumn = NavigationSplitViewColumn.detail @@ -212,6 +213,8 @@ private struct MemberWorkspace: View { account?.openDeletionFix = { [linkRouter] link in linkRouter.receive(link) } _account = State(initialValue: account) // Native staff authority plus at least one staff action of this membership; the server still enforces each call. + _insights = State(initialValue: InsightsState(repository: LiveInsightsRepository(service: auth.service, expectedCredentials: auth.acceptedCredentials, + makerspaceID: id, onUnauthorized: failureHandlers.unauthorized, onForbidden: failureHandlers.forbidden))) let membershipActions = Set(auth.selectedMembership?.actions ?? []) _staff = State(initialValue: auth.staffToolsAvailable && auth.selectedMembership?.id == id && !membershipActions.isDisjoint(with: StaffState.toolActions) ? StaffState(repository: LiveStaffRepository(service: auth.service, expectedCredentials: auth.acceptedCredentials, makerspaceID: id, origin: origin, @@ -262,13 +265,13 @@ private struct MemberWorkspace: View { columns = width >= 1000 ? .all : .detailOnly } }.task { - auth.registerPrivateHolder(privateHolderOwner) { [member, cart, drafts, capabilities, navigation, payments, account, staff] in - member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); account?.invalidate(); staff?.invalidate() + auth.registerPrivateHolder(privateHolderOwner) { [member, cart, drafts, capabilities, navigation, payments, account, staff, insights] in + member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); account?.invalidate(); staff?.invalidate(); insights.invalidate() } async let config: Void = capabilities.load(); await member.loadUnreadCount(); await config }.onDisappear { auth.unregisterPrivateHolder(privateHolderOwner) - member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); account?.invalidate(); staff?.invalidate() + member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); account?.invalidate(); staff?.invalidate(); insights.invalidate() } .onChange(of: capabilities.selfService, initial: true) { _, _ in account?.updateCapabilities(selfService: capabilities.selfService, exports: capabilities.exports) } .onChange(of: capabilities.exports, initial: true) { _, _ in account?.updateCapabilities(selfService: capabilities.selfService, exports: capabilities.exports) } @@ -291,9 +294,9 @@ private struct MemberWorkspace: View { } private func destination(_ tab: AppTab) -> some View { NavigationStack(path: navigation.path(tab)) { - MemberScreen(state: state, auth: auth, member: member, tab: tab, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, payments: payments, account: account, staff: staff, linkRouter: linkRouter) + MemberScreen(state: state, auth: auth, member: member, tab: tab, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, payments: payments, account: account, staff: staff, insights: insights, linkRouter: linkRouter) .navigationDestination(for: MemberRoute.self) { route in - MemberRouteView(route: route, member: member, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, isDemo: false, payments: payments, staff: staff, account: account) { await auth.foreground() } + MemberRouteView(route: route, member: member, actions: actions, cart: cart, drafts: drafts, navigation: navigation, capabilities: capabilities, isDemo: false, payments: payments, staff: staff, account: account, insights: insights) { await auth.foreground() } } } } @@ -313,6 +316,7 @@ private struct MemberScreen: View { let payments: PaymentsState let account: AccountState? let staff: StaffState? + let insights: InsightsState let linkRouter: MobileLinkRouter @State private var accountForm: AccountAccessState? @State private var replayIntro = false @@ -326,7 +330,7 @@ private struct MemberScreen: View { else if let legacy = MobileLink.legacy(text, origin: origin) { linkRouter.receive(legacy) } else if let destination = MobileLink.customDomain(text), let id = auth.selectedID { linkRouter.receive(MobileLink(makerspaceID: id, destination: destination)) } else { linkRouter.refuseURL() } - }) } + }, insights: insights) } else { ScrollView { VStack(alignment: .leading, spacing: 24) { SWCard { @@ -340,6 +344,11 @@ private struct MemberScreen: View { if let error = capabilities.error { SWError(message: error); Button("Retry account options") { Task { await capabilities.load() } }.frame(minHeight: 48) } if capabilities.selfService { NavigationLink("Edit profile", value: MemberRoute.contact).frame(minHeight: 48) } if capabilities.preferences { NavigationLink("Communication preferences", value: MemberRoute.preferences).frame(minHeight: 48) } + if let messaging = capabilities.bootstrap?.messaging, messaging.sms || messaging.whatsapp { + NavigationLink("Text and WhatsApp messages") { + MessagingConsentView(state: insights, openAccount: account == nil ? nil : { navigation.open(.account, tab: .profile) }) + }.frame(minHeight: 48).accessibilityIdentifier("messaging-entry") + } if account != nil { NavigationLink("Account and security", value: MemberRoute.account).frame(minHeight: 48).accessibilityIdentifier("account-entry") } NavigationLink("Payments", value: MemberRoute.payments).frame(minHeight: 48).accessibilityIdentifier("payments-entry") if staff != nil { NavigationLink("Staff tools", value: MemberRoute.staff).frame(minHeight: 48).accessibilityIdentifier("staff-tools") } @@ -373,7 +382,7 @@ private struct MemberScreen: View { Button("Sign out everywhere", role: .destructive) { Task { await auth.signOutEverywhere() - if auth.stage != .signedIn { member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); account?.invalidate(); staff?.invalidate() } + if auth.stage != .signedIn { member.invalidate(); cart.invalidate(); drafts.invalidate(); capabilities.invalidate(); navigation.invalidate(); payments.invalidate(); account?.invalidate(); staff?.invalidate(); insights.invalidate() } } } Button("Cancel", role: .cancel) {} diff --git a/SpaceWorks/SpaceWorksApp.swift b/SpaceWorks/SpaceWorksApp.swift index 7765543..8cc3a2f 100644 --- a/SpaceWorks/SpaceWorksApp.swift +++ b/SpaceWorks/SpaceWorksApp.swift @@ -82,6 +82,9 @@ final class PreviewHealthProtocol: URLProtocol { case "/api/v1/auth/phone/login/start": body = #"{"detail":"If the number can sign in, a code was sent."}"# case "/api/v1/auth/phone/login/confirm": body = DebugAuthFixtures.tokens case "/api/v1/auth/logout-all": body = #"{"web_sessions":1,"devices":2}"# + case "/api/v1/member/makerspaces/7/my-usage", "/api/v1/member/makerspaces/8/my-usage": body = DebugAuthFixtures.usage + case "/api/v1/admin/makerspace/7/dashboard": body = DebugAuthFixtures.dashboard + case "/api/v1/me/messaging": body = DebugAuthFixtures.messaging case "/api/v1/auth/account/deletion": body = deletion ? #"{"deleted_at":"2026-10-18T10:00:00Z","purge_after":"2027-01-16T10:00:00Z"}"# : #"{"deletable":true,"restorable":true,"restore_reason":"","blockers":[],"reauth":{"required":true,"methods":["password"]}}"# diff --git a/SpaceWorks/StaffViews.swift b/SpaceWorks/StaffViews.swift index 3ab0da3..cc342cb 100644 --- a/SpaceWorks/StaffViews.swift +++ b/SpaceWorks/StaffViews.swift @@ -3,12 +3,14 @@ import SpaceWorksAPI struct StaffView: View { @Bindable var state: StaffState + var insights: InsightsState? = nil var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { Text("Staff tools").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) Text("Review, hand over, and welcome people into your makerspace.").foregroundStyle(Color.swMuted) if state.isDemo { Label("Demo staff tools · sample data", systemImage: "info.circle").foregroundStyle(Color.swMuted) } + if let insights, !state.isDemo { StaffGlanceSection(state: insights) } SWCard { if ["accept_request", "reject_request", "issue_request", "return_request"].contains(where: state.can) { NavigationLink { StaffRequestsView(state: state) } label: { StaffMenuRow(title: Text("Requests and loans"), detail: "Review requests and guide equipment handovers", symbol: "shippingbox") }.accessibilityIdentifier("staff-requests") diff --git a/SpaceWorksTests/InsightsTests.swift b/SpaceWorksTests/InsightsTests.swift new file mode 100644 index 0000000..91797a4 --- /dev/null +++ b/SpaceWorksTests/InsightsTests.swift @@ -0,0 +1,116 @@ +import XCTest +import Foundation +import SpaceWorksAPI +@testable import SpaceWorks + +private actor InsightsFake: InsightsRepository { + var messagingJSON = DebugAuthFixtures.messaging + var consentFailure: APIError? + var consentCalls: [(String, Bool)] = [] + func configure(messaging: String) { messagingJSON = messaging } + func configure(consentFailure: APIError?) { self.consentFailure = consentFailure } + private func decode(_ json: String) throws -> T { try JSONDecoder().decode(T.self, from: Data(json.utf8)) } + func usage() async throws -> Components.Schemas.MemberUsage { try decode(DebugAuthFixtures.usage) } + func dashboard() async throws -> Components.Schemas.Dashboard { try decode(DebugAuthFixtures.dashboard) } + func messaging() async throws -> Components.Schemas.MemberMessaging { try decode(messagingJSON) } + func consent(channel: Components.Schemas.Channel06eEnum, optIn: Bool) async throws -> Components.Schemas.MemberMessaging { + consentCalls.append((channel.rawValue, optIn)) + if let consentFailure { throw consentFailure } + return try decode(messagingJSON.replacingOccurrences(of: #""consented":false"#, with: #""consented":\#(optIn)"#)) + } +} + +@MainActor final class InsightsTests: XCTestCase { + func testUsageSeriesUsesTheDeclaredFieldsAndKeepsServerNumbersAsText() throws { + let usage = try JSONDecoder().decode(Components.Schemas.MemberUsage.self, from: Data(DebugAuthFixtures.usage.utf8)) + let series = UsageSeries.from(try XCTUnwrap(usage.loans).monthly) + XCTAssertEqual(series.points, [UsagePoint(label: "2026-09", value: "2", amount: 2), UsagePoint(label: "2026-10", value: "1", amount: 1)]) + XCTAssertEqual(series.unit, "loans"); XCTAssertEqual(series.summary, "Loans by month") + XCTAssertNil(usage.machines, "Absent sections stay absent, never zero") + XCTAssertTrue(UsageSeries.from(try XCTUnwrap(usage.payments).totals).points.isEmpty) + } + + func testDashboardShowsOnlyReportedNonZeroCountersAndHidesAnalyticsForHandover() throws { + let glance = StaffGlance.from(try JSONDecoder().decode(Components.Schemas.Dashboard.self, from: Data(DebugAuthFixtures.dashboard.utf8))) + XCTAssertEqual(glance.counters, [StaffCounter(key: "pending_requests", value: 2)], "Zero and omitted counters are not tiles") + XCTAssertEqual(glance.queue.map(\.key), ["ready_to_issue", "due_returns", "pickups"]) + XCTAssertEqual(glance.outstanding.first?.currency, "INR") + let handover = #"{"scope_mode":"handover","widgets":[{"key":"loans","title":"Loans","required_action":"view_reports","required_modules":[],"kind":"count","report_keys":[],"default_period":7,"data":{"count":4},"chart_spec":null}]}"# + XCTAssertTrue(StaffGlance.from(try JSONDecoder().decode(Components.Schemas.Dashboard.self, from: Data(handover.utf8))).widgets.isEmpty) + let full = handover.replacingOccurrences(of: #""handover""#, with: #""full""#) + XCTAssertEqual(StaffGlance.from(try JSONDecoder().decode(Components.Schemas.Dashboard.self, from: Data(full.utf8))).widgets, + [StaffWidget(key: "loans", title: "Loans", headline: "4")]) + XCTAssertEqual(StaffGlance.label("due_returns"), "Due back") + } + + func testOptInNeedsAVerifiedAvailableUnsuppressedChannel() async { + let fake = InsightsFake() + let state = InsightsState(repository: fake) + await state.refreshMessaging() + await state.setConsent(.sms, optIn: true) + let refused = await fake.consentCalls + XCTAssertTrue(refused.isEmpty, "An unverified phone cannot opt in") + await fake.configure(messaging: DebugAuthFixtures.messaging.replacingOccurrences(of: #""phone_verified":false,"phone_masked":"""#, with: #""phone_verified":true,"phone_masked":"+91 •••• 3210""#)) + await state.refreshMessaging() + await state.setConsent(.sms, optIn: true) + guard case .ready(let value) = state.messaging else { return XCTFail("Consent result must come from the server") } + XCTAssertTrue(value.sms.consented) + } + + func testRefusedConsentShowsTheServerDetailAndRefetches() async { + let fake = InsightsFake() + await fake.configure(messaging: DebugAuthFixtures.messaging.replacingOccurrences(of: #""consented":false,"phone_verified":false"#, with: #""consented":true,"phone_verified":true"#)) + await fake.configure(consentFailure: APIError(status: 409, code: "consent_conflict", detail: "This number changed. Verify it again.")) + let state = InsightsState(repository: fake) + await state.refreshMessaging() + await state.setConsent(.sms, optIn: false) + XCTAssertEqual(state.consentError, "This number changed. Verify it again.") + guard case .ready(let value) = state.messaging else { return XCTFail("The authoritative state is refetched") } + XCTAssertTrue(value.sms.consented) + } + + func testPhoneRestoreNeedsMatchingPasswordsAndNeverStartsASession() async { + var restored: (String, String, String, String)? + let form = AccountRestoreState(begin: { _ in .init(challenge: "bound", expires: Date().addingTimeInterval(180)) }, restore: { phone, code, password, challenge in + restored = (phone, code, password, challenge) + return try JSONDecoder().decode(Components.Schemas.AccountRestoreResult.self, from: Data(#"{"restored":true,"detail":"Restored."}"#.utf8)) + }) + form.number = "+91 98765 43210"; await form.send() + XCTAssertEqual(form.step, .code) + form.code = "123456"; form.password = "New-password-1"; form.confirmation = "different"; await form.submit() + XCTAssertEqual(form.error, "Enter matching new passwords."); XCTAssertNil(restored) + form.confirmation = "New-password-1"; await form.submit() + XCTAssertEqual(form.step, .restored); XCTAssertEqual(form.password, "", "Secrets are cleared") + XCTAssertEqual(restored?.0, "+919876543210"); XCTAssertEqual(restored?.3, "bound") + } + + func testGeneratedInsightOperationsUseTheSelectedMakerspace() async throws { + StubProtocol.lock.lock() + StubProtocol.handler = { request in + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Makerspace-Id"), "7") + let json = ["Content-Type": "application/json"] + switch request.url!.path { + case "/api/v1/member/makerspaces/7/my-usage": return (200, json, Data(DebugAuthFixtures.usage.utf8)) + case "/api/v1/admin/makerspace/7/dashboard": return (200, json, Data(DebugAuthFixtures.dashboard.utf8)) + case "/api/v1/me/messaging": + XCTAssertEqual(URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems?.first?.value, "7") + return (200, json, Data(DebugAuthFixtures.messaging.utf8)) + case "/api/v1/me/messaging/consent": + let body = try JSONSerialization.jsonObject(with: requestBody(request)) as? [String: Any] + XCTAssertEqual(body?["makerspace"] as? Int, 7); XCTAssertEqual(body?["channel"] as? String, "sms"); XCTAssertEqual(body?["action"] as? String, "opted_out") + return (200, json, Data(DebugAuthFixtures.messaging.utf8)) + default: XCTFail("Unexpected request \(request.url!)"); return (500, json, Data("{}".utf8)) + } + } + StubProtocol.lock.unlock() + let configuration = DeviceSession.configuration(); configuration.protocolClasses = [StubProtocol.self] + let service = SpaceWorksService(origin: try ServerOrigin("https://spaceworks.example"), session: DeviceSession.make(configuration: configuration), + initialTokens: TokenPair(access: "fixture-access", refresh: "fixture-refresh")) + try await service.makerspace.select(7, activeMemberships: [7]) + let repository = LiveInsightsRepository(service: service, expectedCredentials: await service.refresh.snapshot(), makerspaceID: 7) + let usage = try await repository.usage(); XCTAssertTrue(usage.usage_available) + let dashboard = try await repository.dashboard(); XCTAssertEqual(dashboard.scope_mode, .full) + _ = try await repository.messaging() + _ = try await repository.consent(channel: .sms, optIn: false) + } +} diff --git a/SpaceWorksUITests/NativeAuthFlowTests.swift b/SpaceWorksUITests/NativeAuthFlowTests.swift index 679b6a3..4ea745e 100644 --- a/SpaceWorksUITests/NativeAuthFlowTests.swift +++ b/SpaceWorksUITests/NativeAuthFlowTests.swift @@ -50,6 +50,10 @@ final class NativeAuthFlowTests: XCTestCase { NativeUIActions.tap(code, in: app) code.typeText("123456") attach("Phone verification code") + // A tablet form sheet re-centres when the keyboard hides; settle the layout before acting. + let done = app.buttons["Done"].firstMatch + if done.waitForExistence(timeout: 3) { done.tap() } + XCTAssertTrue(app.keyboards.firstMatch.waitForNonExistence(timeout: 5)) NativeUIActions.tap(app.buttons["phone-verify"], in: app) XCTAssertTrue(app.buttons["makerspace-7"].waitForExistence(timeout: 10)) } From e4113bcf1dd47940fd0d2c9161fc5a89262cae68 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:48:59 +0530 Subject: [PATCH 96/98] docs(d8): record v2 extras and native app lock in the phase note Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- docs/CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e74a7cd..ef83b1d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -46,6 +46,17 @@ passcode) lock per account using LocalAuthentication device-owner authentication it backgrounds and on a cold launch, shows a lock screen with Unlock and Sign out, and never stores biometric data. The Face ID purpose string is generated and checked in the Release audit. +**D8 v2 PRD extras (parity with Android 8e86c52).** Home adds "Your activity" from the generated +`my-usage` operation: loans, machine time, bookings, events attended and payments per currency, each +series reduced to its declared x field and first measure with values written out; absent sections +are hidden rather than shown as zero. Staff tools add "Today at a glance" from the generated +dashboard: non-zero reported counters, the handover queue, outstanding payments per currency and +report headlines (handover scope shows no analytics). Profile adds "Text and WhatsApp messages" +when the bootstrap enables SMS or WhatsApp; opt-in needs an available, verified, unsuppressed +channel, and a refusal shows the server detail and refetches. Sign-in adds "Restore a deleted +account" by phone; it never returns a session. Swift's generator handles `NullEnum` without a +mapping, and omitted dashboard counters decode as `nil` rather than the schema default. + **D6 hardening.** The privacy manifest declares no tracking and the account-linked data the app sends for app functionality. New copy is seeded in the English catalog. Documentation records the owner inputs below. @@ -72,6 +83,8 @@ Parity checklist (shared copy recorded for the Android session): - [x] Staff tools entry only for native staff with a staff action. - [x] Delete account: Check account obligations, Resolve these first, Open in the app, Open in the web console, Confirm it's you, Type DELETE to confirm, Delete account. - [x] Push/inbox/universal links follow the contract; unsupported links open the makerspace inbox. +- [x] Your activity, Today at a glance, Text and WhatsApp messages and Restore a deleted account use the Android copy recorded in the shared notes. +- [x] Optional app lock ("Require Face ID" / Touch ID / Optic ID / device passcode) is iOS-native; Android's equivalent is platform-specific. - [x] Light/dark, Dynamic Type headings and 48 pt actions on every new screen; phone and iPad widths constrained. ## C7 — Preserve independent push and pull-request validation (2026-10-18) From 6dd62c7a54ea5f9612bcf71c74b8b004bba095cf Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:45:23 +0530 Subject: [PATCH 97/98] test(d2): tap phone Verify only after the tablet sheet layout settles Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- SpaceWorksUITests/NativeAuthFlowTests.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/SpaceWorksUITests/NativeAuthFlowTests.swift b/SpaceWorksUITests/NativeAuthFlowTests.swift index 4ea745e..5f7427b 100644 --- a/SpaceWorksUITests/NativeAuthFlowTests.swift +++ b/SpaceWorksUITests/NativeAuthFlowTests.swift @@ -50,11 +50,18 @@ final class NativeAuthFlowTests: XCTestCase { NativeUIActions.tap(code, in: app) code.typeText("123456") attach("Phone verification code") - // A tablet form sheet re-centres when the keyboard hides; settle the layout before acting. + // A tablet form sheet re-centres when the keyboard hides; act only once Verify stops moving. let done = app.buttons["Done"].firstMatch if done.waitForExistence(timeout: 3) { done.tap() } - XCTAssertTrue(app.keyboards.firstMatch.waitForNonExistence(timeout: 5)) - NativeUIActions.tap(app.buttons["phone-verify"], in: app) + let verify = app.buttons["phone-verify"] + var frame = verify.frame + for _ in 0..<20 { + Thread.sleep(forTimeInterval: 0.5) + let next = verify.frame + if next == frame { break } + frame = next + } + NativeUIActions.tap(verify, in: app) XCTAssertTrue(app.buttons["makerspace-7"].waitForExistence(timeout: 10)) } From e4a8c8e50d8d855d34dab6aba2e825037ba04ba2 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath <147940963+Shaan-Shoukath@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:54:22 +0530 Subject: [PATCH 98/98] fix(d2): verify a complete phone code automatically and settle tablet guide sidebar Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- SpaceWorks/NativeSignInViews.swift | 5 +++++ SpaceWorksUITests/GuideFlowTests.swift | 5 +++-- SpaceWorksUITests/NativeAuthFlowTests.swift | 15 ++------------- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/SpaceWorks/NativeSignInViews.swift b/SpaceWorks/NativeSignInViews.swift index 09cc5ff..02448a5 100644 --- a/SpaceWorks/NativeSignInViews.swift +++ b/SpaceWorks/NativeSignInViews.swift @@ -120,6 +120,11 @@ struct PhoneSignInView: View { Text("Verification code").brandFont(.headline) TextField("Verification code", text: $form.code) .textContentType(.oneTimeCode).keyboardType(.numberPad).focused($focused) + .submitLabel(.go).onSubmit { Task { await form.verify() } } + // A complete code (typed or filled from Messages) verifies once; the button stays as a fallback. + .onChange(of: form.code) { _, value in + if value.count == 6, value.allSatisfy({ $0.isASCII && $0.isNumber }), !form.busy { focused = false; Task { await form.verify() } } + } .padding(16).background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)).accessibilityIdentifier("phone-code") if let error = form.error { SWError(message: error) } diff --git a/SpaceWorksUITests/GuideFlowTests.swift b/SpaceWorksUITests/GuideFlowTests.swift index f8b7606..82ee9ea 100644 --- a/SpaceWorksUITests/GuideFlowTests.swift +++ b/SpaceWorksUITests/GuideFlowTests.swift @@ -22,10 +22,11 @@ final class GuideFlowTests: XCTestCase { if XCTWaiter.wait(for: [ready], timeout: 3) != .completed { let sidebar = app.navigationBars.buttons.matching(NSPredicate(format: "label ==[c] %@ OR label ==[c] %@", "Show Sidebar", "Toggle Sidebar")).firstMatch let sidebarReady = XCTNSPredicateExpectation(predicate: readiness, object: sidebar) - XCTAssertEqual(XCTWaiter.wait(for: [sidebarReady], timeout: 3), .completed) + // Tablet accessibility evaluation can be slow; match the ten-second sidebar bound used elsewhere. + XCTAssertEqual(XCTWaiter.wait(for: [sidebarReady], timeout: 10), .completed) sidebar.tap() let opened = XCTNSPredicateExpectation(predicate: readiness, object: target) - XCTAssertEqual(XCTWaiter.wait(for: [opened], timeout: 3), .completed) + XCTAssertEqual(XCTWaiter.wait(for: [opened], timeout: 10), .completed) } tap(target, in: app) } diff --git a/SpaceWorksUITests/NativeAuthFlowTests.swift b/SpaceWorksUITests/NativeAuthFlowTests.swift index 5f7427b..e1a1f91 100644 --- a/SpaceWorksUITests/NativeAuthFlowTests.swift +++ b/SpaceWorksUITests/NativeAuthFlowTests.swift @@ -48,20 +48,9 @@ final class NativeAuthFlowTests: XCTestCase { let code = app.textFields["phone-code"] XCTAssertTrue(code.waitForExistence(timeout: 10)) NativeUIActions.tap(code, in: app) - code.typeText("123456") attach("Phone verification code") - // A tablet form sheet re-centres when the keyboard hides; act only once Verify stops moving. - let done = app.buttons["Done"].firstMatch - if done.waitForExistence(timeout: 3) { done.tap() } - let verify = app.buttons["phone-verify"] - var frame = verify.frame - for _ in 0..<20 { - Thread.sleep(forTimeInterval: 0.5) - let next = verify.frame - if next == frame { break } - frame = next - } - NativeUIActions.tap(verify, in: app) + // A complete six-digit code verifies automatically, like a code filled from Messages. + code.typeText("123456") XCTAssertTrue(app.buttons["makerspace-7"].waitForExistence(timeout: 10)) }

buA1Q>Oet=n4pT^RQ3dxEl{w28>L< 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�v5V*WJjLmyx6|XPWbe%Wm&Cx#HQEQ!4IMNF*~A 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 VIj27a)SCRIa6|cex(w{;{{hb9Dk1;? literal 0 HcmV?d00001 diff --git a/docs/images/onboarding.png b/docs/images/onboarding.png new file mode 100644 index 0000000000000000000000000000000000000000..8651e63085b953e838693feac086f713a0b6ed45 GIT binary patch literal 165653 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 TlX!qkY`c2-#-)tEjh_Aw;p@u# literal 0 HcmV?d00001 diff --git a/docs/images/provenance.json b/docs/images/provenance.json new file mode 100644 index 0000000..05d36a6 --- /dev/null +++ b/docs/images/provenance.json @@ -0,0 +1,99 @@ +{ + "schema_version": 1, + "capture_source_sha": "775f2980d2da7c8a1412024b003d485fe33cc2b7", + "capture_run": 39101474324, + "purpose": "original historical UI gallery; not final whole-matrix acceptance", + "global_matrix_acceptance": false, + "assets": [ + { + "destination": "docs/images/onboarding.png", + "sha256": "ed8c8b903a5df85fc537e6e339e728ed718a2bee8d9cc35446425d3e0bedee3a", + "type": "screenshot", + "source": { + "source_sha": "775f2980d2da7c8a1412024b003d485fe33cc2b7", + "run": 39101474324, + "artifact_id": 12127604558, + "job_id": 117598452693, + "profile": "phone", + "test": "MainFlowTests/testOptionalIntroductionCompletesAndStaysDismissed()", + "prefix": "Introduction first page", + "artifact_path": "TestAttachments/87627424-90AB-403D-8D6F-D798D2405105.png", + "profile_job_conclusion": "success" + } + }, + { + "destination": "docs/images/inventory.png", + "sha256": "b4c9afdc9a910b13c42fccbf3f4db89a70fa4c4a0f0c09ecee872c0985b820f1", + "type": "screenshot", + "source": { + "source_sha": "775f2980d2da7c8a1412024b003d485fe33cc2b7", + "run": 39101474324, + "artifact_id": 12127604558, + "job_id": 117598452693, + "profile": "phone", + "test": "MemberFlowTests/testMemberInventorySearchDetailAndInboxRead()", + "prefix": "Inventory item detail", + "artifact_path": "TestAttachments/E2A582ED-9855-424D-8902-76B4C18F52BA.png", + "profile_job_conclusion": "success" + } + }, + { + "destination": "docs/images/request-review.png", + "sha256": "2942ef27d9d453fc970f0fb1ff6dc5c747c414437ebd44e17583b2adcc983bbf", + "type": "screenshot", + "source": { + "source_sha": "775f2980d2da7c8a1412024b003d485fe33cc2b7", + "run": 39101474324, + "artifact_id": 12127604558, + "job_id": 117598452693, + "profile": "phone", + "test": "MemberActionsFlowTests/testReviewedRequestSubmissionAndExplicitLoanRenewal()", + "prefix": "Request review before submission", + "artifact_path": "TestAttachments/E3C3EF7B-8585-40F3-B56F-784E7A8C1B8E.png", + "profile_job_conclusion": "success" + } + }, + { + "destination": "docs/images/ipad-sign-in.png", + "sha256": "925594035350e06e3be63b1fbab9d33dc0d3558f6c213463f73afa1862e6d9e7", + "type": "screenshot", + "source": { + "source_sha": "775f2980d2da7c8a1412024b003d485fe33cc2b7", + "run": 39101474324, + "artifact_id": 12127624212, + "job_id": 117598452666, + "profile": "large-tablet", + "test": "MainFlowTests/testRegularTextDarkLandscapeSignInUsesNativeBrandPanel()", + "prefix": "Sign in regular text dark landscape", + "artifact_path": "TestAttachments/626DECD9-917A-4023-97D0-03F5D17257A3.png", + "profile_job_conclusion": "success" + } + }, + { + "destination": "docs/images/ipad-profile.png", + "sha256": "b58c096b43f07b1707bb156530eb5d60e34801337455a06116e6a282a07026c9", + "type": "screenshot", + "source": { + "source_sha": "775f2980d2da7c8a1412024b003d485fe33cc2b7", + "run": 39101474324, + "artifact_id": 12127624212, + "job_id": 117598452666, + "profile": "large-tablet", + "test": "MemberActionsFlowTests/testProfileDraftSurvivesRotationAndOptionalPreferencesCanChange()", + "prefix": "Native profile saved landscape", + "artifact_path": "TestAttachments/D584002F-5404-4D46-952C-E07052407DF8.png", + "profile_job_conclusion": "success" + } + }, + { + "destination": "docs/images/spaceworks-icon.png", + "sha256": "1b145ed6fe56fccd266c1e705c15f818b4b8e31311ce84d30d369ae332375dd5", + "type": "logo", + "source": { + "repository": "design", + "commit": "fd28f825086a2cac7fa297881f1860b9d332db69", + "path": "logo/app-icon.png" + } + } + ] +} diff --git a/docs/images/request-review.png b/docs/images/request-review.png new file mode 100644 index 0000000000000000000000000000000000000000..429746f1fb009ccf9c49ca4d22757634135182cf GIT binary patch literal 217561 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%0D$-WS4)yQTG?-yN+R*V}lw?m$BH36k;%d(vj~ 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

mS^ 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 z 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%0DGY+<3@1YnTug*9QFrJmg`hL&EW|`w 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 z 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 e%=UA%{K>OE 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